From bfc31122aa6424bab82e3656f3bb258badccc3b4 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:16:14 -0400 Subject: [PATCH 1/8] bug: chat mute, first-DM badge, tab focus stealing, tournament chat entry The chat notification sound now honours the sound setting like the other sounds. A direct message from someone not yet on the rail badges its new tab with 1 unless that conversation is on screen; an existing tab is still counted by its room's live handler. Lobby, match, organizer and tournament rooms that the session opens on its own no longer take the active tab, so the restore step that undid it is gone. ChatPanel's first mount opens the room already requested (a Message button, the stored room) instead of the first room. The tournament page gets a Chat Room entry for tournaments in the chat hub's list, with the room's unread count, which opens the hub on that room. --- components/hub/ChatPanel.vue | 11 +- components/tournament/TournamentDetail.vue | 66 ++++++++++ composables/useChatTabSetup.ts | 47 ++++--- composables/useChatTabs.ts | 13 +- composables/useIncomingDirectMessages.ts | 31 +++-- composables/useSound.ts | 2 +- i18n/locales/ar_SA.json | 1 + i18n/locales/da_DK.json | 1 + i18n/locales/de_DE.json | 1 + i18n/locales/en.json | 1 + i18n/locales/es_ES.json | 1 + i18n/locales/fr_FR.json | 1 + i18n/locales/it_IT.json | 1 + i18n/locales/ja_JP.json | 1 + i18n/locales/ko_KR.json | 1 + i18n/locales/pl_PL.json | 1 + i18n/locales/pt_BR.json | 1 + i18n/locales/ru_RU.json | 1 + i18n/locales/sv_SE.json | 1 + i18n/locales/tr_TR.json | 1 + i18n/locales/uk_UA.json | 1 + i18n/locales/zh_Hans.json | 1 + i18n/locales/zh_Hant.json | 1 + tests/components/ChatPanel.spec.ts | 61 +++++++++ tests/composables/useChatTabSetup.spec.ts | 110 ++++++++++++++++ .../useIncomingDirectMessages.spec.ts | 117 ++++++++++++++++++ tests/composables/useSound.spec.ts | 68 ++++++++++ 27 files changed, 503 insertions(+), 40 deletions(-) create mode 100644 tests/components/ChatPanel.spec.ts create mode 100644 tests/composables/useChatTabSetup.spec.ts create mode 100644 tests/composables/useIncomingDirectMessages.spec.ts create mode 100644 tests/composables/useSound.spec.ts diff --git a/components/hub/ChatPanel.vue b/components/hub/ChatPanel.vue index 3a687706a..522854cf8 100644 --- a/components/hub/ChatPanel.vue +++ b/components/hub/ChatPanel.vue @@ -169,12 +169,19 @@ const showChatIndicator = computed( () => activeChatId.value && chatIndicatorHeight.value > 0, ); +// The room already asked for through useChatTabs wins over the first one: this +// runs before the activeTabId watcher below, so on the panel's first mount +// picking tabs[0] here would overwrite a Message button or a restored room. +function requestedOrFirstRoom(tabs: ChatTab[]) { + return tabs.find((tab) => tab.id === activeTabId.value) ?? tabs[0]; +} + // Default to first room when panel becomes active with no selection watch( () => props.isTabActive, (active) => { if (active && !activeChatId.value && orderedTabs.value.length > 0) { - handleSelectRoom(orderedTabs.value[0]); + handleSelectRoom(requestedOrFirstRoom(orderedTabs.value)); } }, ); @@ -188,7 +195,7 @@ watch( activeChatId.value = next?.id ?? null; } if (!activeChatId.value && tabs.length > 0) { - handleSelectRoom(tabs[0]); + handleSelectRoom(requestedOrFirstRoom(tabs)); } }, { immediate: true }, diff --git a/components/tournament/TournamentDetail.vue b/components/tournament/TournamentDetail.vue index 7cdb01a63..2293e5fa5 100644 --- a/components/tournament/TournamentDetail.vue +++ b/components/tournament/TournamentDetail.vue @@ -42,6 +42,7 @@ import { MapPin, Minimize, Maximize, + MessageSquare, } from "lucide-vue-next"; import AnimatedFilters from "~/components/common/AnimatedFilters.vue"; import { Button } from "@/components/ui/button"; @@ -153,6 +154,10 @@ const tournamentHeroJoinButtonClasses = [ const tournamentHeroSettingsButtonClasses = "h-9 w-9 border-[hsl(var(--tac-amber)_/_0.45)] bg-background/45 text-[hsl(var(--tac-amber))] hover:bg-[hsl(var(--tac-amber)_/_0.12)] hover:text-[hsl(var(--tac-amber))]"; const tournamentHeroTabsClasses = "mt-5 border-t border-border pt-4"; +const tournamentChatRoomTabClasses = + "relative z-[1] inline-flex items-center justify-center whitespace-nowrap rounded-md ring-offset-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"; +const tournamentChatRoomUnreadClasses = + "inline-flex h-4 min-w-[1.05rem] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] leading-none tracking-normal text-white tabular-nums"; const tacticalSectionCountClasses = "rounded-full border border-[hsl(var(--tac-amber)_/_0.4)] bg-[hsl(var(--tac-amber)_/_0.12)] px-[0.45rem] py-[0.05rem] text-[0.62rem] tracking-[0.08em] text-[hsl(var(--tac-amber))]"; const tournamentTeamCardClasses = @@ -582,6 +587,24 @@ function clearTeamEnterDelay(el: Element) { > {{ $t("tournament.notifications.title") }} + @@ -1141,6 +1164,12 @@ import { normalizeRouteTab, replaceRouteTab, } from "~/composables/useRouteTab"; +import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; +import { useChatTabs } from "~/composables/useChatTabs"; +import { tournamentChatTab } from "~/composables/useChatTabSetup"; +import { cancelChatTabRestore } from "~/composables/useChatTabPersistence"; +import { setActiveHub } from "~/composables/useHubState"; +import { useRightSidebar } from "~/composables/useRightSidebar"; export default { data() { @@ -1847,6 +1876,31 @@ export default { } return { ...this.tournamentStatic, ...this.tournamentLive }; }, + chatRoomTournament(): { id: string; name: string } | undefined { + const id = this.tournament?.id; + if (!id) { + return undefined; + } + return ( + useMatchLobbyStore().chatTournaments as Array<{ + id: string; + name: string; + }> + ).find((candidate) => candidate.id === id); + }, + chatRoomUnreadLabel(): string { + if (!this.chatRoomTournament) { + return ""; + } + const unread = + useChatTabs().unreadCounts.value[ + tournamentChatTab(this.chatRoomTournament).id + ] ?? 0; + if (unread <= 0) { + return ""; + } + return unread > 100 ? "100+" : String(unread); + }, leagueSeasonId() { return this.$route.params.seasonId ?? null; }, @@ -2164,6 +2218,18 @@ export default { }, }, methods: { + openChatRoom() { + if (!this.chatRoomTournament) { + return; + } + cancelChatTabRestore(); + useChatTabs().openTab({ + ...tournamentChatTab(this.chatRoomTournament), + activate: true, + }); + setActiveHub("chat"); + useRightSidebar().setRightSidebarOpen(true); + }, refetchTournamentStatic() { return this.$apollo?.queries?.tournamentStatic?.refetch(); }, diff --git a/composables/useChatTabSetup.ts b/composables/useChatTabSetup.ts index 924c21cf9..004531441 100644 --- a/composables/useChatTabSetup.ts +++ b/composables/useChatTabSetup.ts @@ -1,12 +1,26 @@ import { watch, computed } from "vue"; import { useI18n } from "vue-i18n"; -import { useChatTabs } from "~/composables/useChatTabs"; +import { useChatTabs, type ChatTab } from "~/composables/useChatTabs"; import { useChatReadState } from "~/composables/useChatReadState"; import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; import { useAuthStore } from "~/stores/AuthStore"; import { e_player_roles_enum } from "~/generated/zeus"; import socket, { type Lobby } from "~/web-sockets/Socket"; +export function tournamentChatTab(tournament: { + id: string; + name: string; +}): ChatTab { + return { + id: `tournament:${tournament.id}`, + label: tournament.name, + instance: "tournament", + type: "tournament", + lobbyId: tournament.id, + pinned: true, + }; +} + export function useChatTabSetup() { const { t } = useI18n(); const { @@ -14,7 +28,6 @@ export function useChatTabSetup() { activeTabId, openTab, closeTab, - setActiveTab, setPinned, incrementUnread, setUnread, @@ -104,25 +117,18 @@ export function useChatTabSetup() { // Ensure a pinned chat tab for every chat-eligible tournament. for (const t of tournaments) { - const tabId = `tournament:${t.id}`; - const existing = tabs.value.find((tab) => tab.id === tabId); + const tab = tournamentChatTab(t); + const existing = tabs.value.find((candidate) => candidate.id === tab.id); if (!existing) { - openTab({ - id: tabId, - label: t.name, - instance: "tournament", - type: "tournament", - lobbyId: t.id, // pass tournament ID as lobby id - pinned: true, - }); + openTab({ ...tab, activate: false }); } else if (!existing.pinned) { - setPinned(tabId, true); + setPinned(tab.id, true); } } // Remove tournament chat tabs that are no longer eligible. const activeIds = new Set( - tournaments.map((t: any) => `tournament:${t.id}`), + tournaments.map((t: any) => tournamentChatTab(t).id), ); for (const tab of [...tabs.value]) { if (tab.type === "tournament" && !activeIds.has(tab.id)) { @@ -132,8 +138,6 @@ export function useChatTabSetup() { } function ensureDefaultTabs() { - const previousActiveId = activeTabId.value; - const me = authStore.me; if (me?.current_lobby_id) { const lobbyTabId = `matchmaking:${me.current_lobby_id}`; @@ -146,6 +150,7 @@ export function useChatTabSetup() { type: "matchmaking", lobbyId: me.current_lobby_id, pinned: true, + activate: false, }); } } @@ -164,6 +169,7 @@ export function useChatTabSetup() { type: "match", lobbyId: currentMatch.id, pinned: true, + activate: false, }); } } @@ -180,6 +186,7 @@ export function useChatTabSetup() { type: "organizers", lobbyId: organizerId, pinned: true, + activate: false, }); } else if (!existing.pinned) { setPinned(organizerId, true); @@ -187,14 +194,6 @@ export function useChatTabSetup() { } else if (existingOrganizer) { closeTab(organizerId); } - - // Restore previously active tab so adding defaults doesn't steal focus. - if (previousActiveId) { - const stillExists = tabs.value.find((t) => t.id === previousActiveId); - if (stillExists) { - setActiveTab(previousActiveId); - } - } } watch( diff --git a/composables/useChatTabs.ts b/composables/useChatTabs.ts index 86b8ee0a2..4a521310e 100644 --- a/composables/useChatTabs.ts +++ b/composables/useChatTabs.ts @@ -31,16 +31,17 @@ export function useChatTabs() { return tabsRef.value.findIndex((t) => t.id === id); } - // `activate` exists for incoming direct messages: a tab opened because - // someone messaged you must not yank you out of the room you are reading. + // `activate: false` opens a tab in the background -- an incoming direct + // message, or a room the session adds on its own -- so it does not yank you + // out of the room you are reading. function openTab( payload: Omit & { pinned?: boolean; activate?: boolean; }, ) { - const id = payload.id; - const activate = payload.activate ?? true; + const { activate = true, ...fields } = payload; + const id = fields.id; const existingIndex = findTabIndex(id); if (existingIndex !== -1) { @@ -51,8 +52,8 @@ export function useChatTabs() { } const tab: ChatTab = { - ...payload, - pinned: payload.pinned ?? false, + ...fields, + pinned: fields.pinned ?? false, }; tabsRef.value.push(tab); diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index c1a14ab18..e0223f44e 100644 --- a/composables/useIncomingDirectMessages.ts +++ b/composables/useIncomingDirectMessages.ts @@ -1,5 +1,6 @@ import { watch } from "vue"; import socket from "~/web-sockets/Socket"; +import { currentHub } from "~/composables/useHubState"; import { directTabId, peerSteamId, @@ -14,8 +15,9 @@ import { // Mounted once, from the default layout. export function useIncomingDirectMessages() { const authStore = useAuthStore(); - const { openTab, closeTab, setUnread, tabs } = useChatTabs(); + const { openTab, closeTab, setUnread, tabs, activeTabId } = useChatTabs(); const { topPosition } = useDirectConversationBar(); + const { rightSidebarOpen } = useRightSidebar(); function ensureTab( roomId: string, @@ -104,12 +106,27 @@ export function useIncomingDirectMessages() { // Deliberately does not inject the message: opening the tab makes // useChatTabSetup join the room, and the join's history snapshot delivers // it (deduped by chatMessageKey either way). - ensureTab(data.roomId, { - steam_id: - data.from?.steam_id ?? peerSteamId(data.roomId, steamId) ?? "", - name: data.from?.name, - avatar_url: data.from?.avatar_url, - }); + // + // Only a tab this creates is counted here. An existing tab is already in + // the room and its live lobby:chat handler counts the message, while the + // snapshot a new tab joins into is never counted for conversations. + const tabId = directTabId(data.roomId); + const isNew = !tabs.value.some((tab) => tab.id === tabId); + const isOnScreen = + activeTabId.value === tabId && + rightSidebarOpen.value && + currentHub() === "chat"; + + ensureTab( + data.roomId, + { + steam_id: + data.from?.steam_id ?? peerSteamId(data.roomId, steamId) ?? "", + name: data.from?.name, + avatar_url: data.from?.avatar_url, + }, + isNew && !isOnScreen ? 1 : 0, + ); }, ); diff --git a/composables/useSound.ts b/composables/useSound.ts index e515dd617..d01c798bd 100644 --- a/composables/useSound.ts +++ b/composables/useSound.ts @@ -92,7 +92,7 @@ export const useSound = () => { }; const playNotificationSound = () => { - if (isInGame()) { + if (!import.meta.client || !isEnabled.value || isInGame()) { return; } diff --git a/i18n/locales/ar_SA.json b/i18n/locales/ar_SA.json index 4d1cf275e..fe21b3b43 100644 --- a/i18n/locales/ar_SA.json +++ b/i18n/locales/ar_SA.json @@ -5695,6 +5695,7 @@ "information_tab": "معلومات", "about_section": "نبذة", "match_settings": "إعدادات المباراة", + "chat_room_tab": "غرفة الدردشة", "read_more": "اقرأ المزيد", "read_less": "اعرض أقل" }, diff --git a/i18n/locales/da_DK.json b/i18n/locales/da_DK.json index 3c6c3d7c8..ddcdca067 100644 --- a/i18n/locales/da_DK.json +++ b/i18n/locales/da_DK.json @@ -5695,6 +5695,7 @@ "information_tab": "Information", "about_section": "Om", "match_settings": "Kampindstillinger", + "chat_room_tab": "Chatrum", "read_more": "Læs mere", "read_less": "Læs mindre" }, diff --git a/i18n/locales/de_DE.json b/i18n/locales/de_DE.json index 2d7b61246..7c79664ce 100644 --- a/i18n/locales/de_DE.json +++ b/i18n/locales/de_DE.json @@ -5695,6 +5695,7 @@ "information_tab": "Informationen", "about_section": "Über", "match_settings": "Match-Einstellungen", + "chat_room_tab": "Chatraum", "read_more": "Mehr anzeigen", "read_less": "Weniger anzeigen" }, diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 9fba3ad1d..674f584b3 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -5695,6 +5695,7 @@ "information_tab": "Information", "about_section": "About", "match_settings": "Match Settings", + "chat_room_tab": "Chat Room", "read_more": "Read more", "read_less": "Read less" }, diff --git a/i18n/locales/es_ES.json b/i18n/locales/es_ES.json index 183dc334f..53ddbc8e3 100644 --- a/i18n/locales/es_ES.json +++ b/i18n/locales/es_ES.json @@ -5695,6 +5695,7 @@ "information_tab": "Información", "about_section": "Acerca de", "match_settings": "Ajustes de partida", + "chat_room_tab": "Sala de chat", "read_more": "Leer más", "read_less": "Leer menos" }, diff --git a/i18n/locales/fr_FR.json b/i18n/locales/fr_FR.json index e0de46625..117aca50a 100644 --- a/i18n/locales/fr_FR.json +++ b/i18n/locales/fr_FR.json @@ -5695,6 +5695,7 @@ "information_tab": "Informations", "about_section": "À propos", "match_settings": "Paramètres de match", + "chat_room_tab": "Salon de discussion", "read_more": "Lire plus", "read_less": "Lire moins" }, diff --git a/i18n/locales/it_IT.json b/i18n/locales/it_IT.json index d8cfa6d78..1c067c8f5 100644 --- a/i18n/locales/it_IT.json +++ b/i18n/locales/it_IT.json @@ -5695,6 +5695,7 @@ "information_tab": "Informazioni", "about_section": "Info", "match_settings": "Impostazioni match", + "chat_room_tab": "Stanza chat", "read_more": "Leggi di più", "read_less": "Leggi meno" }, diff --git a/i18n/locales/ja_JP.json b/i18n/locales/ja_JP.json index d2721c228..8fa6cf23d 100644 --- a/i18n/locales/ja_JP.json +++ b/i18n/locales/ja_JP.json @@ -5695,6 +5695,7 @@ "information_tab": "情報", "about_section": "概要", "match_settings": "試合設定", + "chat_room_tab": "チャットルーム", "read_more": "もっと見る", "read_less": "折りたたむ" }, diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index e64ad9587..33b80e74b 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -5695,6 +5695,7 @@ "information_tab": "정보", "about_section": "소개", "match_settings": "매치 설정", + "chat_room_tab": "채팅방", "read_more": "더 보기", "read_less": "접기" }, diff --git a/i18n/locales/pl_PL.json b/i18n/locales/pl_PL.json index 94a7f517e..02a211432 100644 --- a/i18n/locales/pl_PL.json +++ b/i18n/locales/pl_PL.json @@ -5695,6 +5695,7 @@ "information_tab": "Informacje", "about_section": "O turnieju", "match_settings": "Ustawienia meczu", + "chat_room_tab": "Czat", "read_more": "Czytaj więcej", "read_less": "Zwiń" }, diff --git a/i18n/locales/pt_BR.json b/i18n/locales/pt_BR.json index 3372270b8..37a5cb513 100644 --- a/i18n/locales/pt_BR.json +++ b/i18n/locales/pt_BR.json @@ -5695,6 +5695,7 @@ "information_tab": "Informações", "about_section": "Sobre", "match_settings": "Configurações da Partida", + "chat_room_tab": "Sala de chat", "read_more": "Ler mais", "read_less": "Ler menos" }, diff --git a/i18n/locales/ru_RU.json b/i18n/locales/ru_RU.json index 5ec94ce17..0fe8d2441 100644 --- a/i18n/locales/ru_RU.json +++ b/i18n/locales/ru_RU.json @@ -5695,6 +5695,7 @@ "information_tab": "Информация", "about_section": "О турнире", "match_settings": "Настройки матча", + "chat_room_tab": "Чат", "read_more": "Читать далее", "read_less": "Свернуть" }, diff --git a/i18n/locales/sv_SE.json b/i18n/locales/sv_SE.json index f1a701591..942e0f2cf 100644 --- a/i18n/locales/sv_SE.json +++ b/i18n/locales/sv_SE.json @@ -5695,6 +5695,7 @@ "information_tab": "Information", "about_section": "Om", "match_settings": "Matchinställningar", + "chat_room_tab": "Chattrum", "read_more": "Läs mer", "read_less": "Visa mindre" }, diff --git a/i18n/locales/tr_TR.json b/i18n/locales/tr_TR.json index dcf9cb6f8..a53d0ecbf 100644 --- a/i18n/locales/tr_TR.json +++ b/i18n/locales/tr_TR.json @@ -5695,6 +5695,7 @@ "information_tab": "Bilgi", "about_section": "Hakkında", "match_settings": "Maç ayarları", + "chat_room_tab": "Sohbet Odası", "read_more": "Devamını oku", "read_less": "Daha az göster" }, diff --git a/i18n/locales/uk_UA.json b/i18n/locales/uk_UA.json index a43e17321..0a167203e 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -5695,6 +5695,7 @@ "information_tab": "Інформація", "about_section": "Про турнір", "match_settings": "Налаштування матчу", + "chat_room_tab": "Чат", "read_more": "Читати більше", "read_less": "Згорнути" }, diff --git a/i18n/locales/zh_Hans.json b/i18n/locales/zh_Hans.json index 72fef4821..ffeb03694 100644 --- a/i18n/locales/zh_Hans.json +++ b/i18n/locales/zh_Hans.json @@ -5695,6 +5695,7 @@ "information_tab": "信息", "about_section": "简介", "match_settings": "比赛设置", + "chat_room_tab": "聊天室", "read_more": "展开", "read_less": "收起" }, diff --git a/i18n/locales/zh_Hant.json b/i18n/locales/zh_Hant.json index c0afa1f0b..c92efdf16 100644 --- a/i18n/locales/zh_Hant.json +++ b/i18n/locales/zh_Hant.json @@ -5695,6 +5695,7 @@ "information_tab": "資訊", "about_section": "關於", "match_settings": "比賽設定", + "chat_room_tab": "聊天室", "read_more": "顯示更多", "read_less": "顯示較少" }, diff --git a/tests/components/ChatPanel.spec.ts b/tests/components/ChatPanel.spec.ts new file mode 100644 index 000000000..a8d1b67c2 --- /dev/null +++ b/tests/components/ChatPanel.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import ChatPanel from "~/components/hub/ChatPanel.vue"; +import { useChatTabs } from "~/composables/useChatTabs"; + +vi.mock("~/web-sockets/Socket", async (importOriginal) => ({ + ...(await importOriginal()), + default: { + listen: () => ({ stop() {} }), + joinLobby: () => ({ messages: [], on() {}, leave() {} }), + markLobbyRead() {}, + }, +})); + +const REQUESTED = "direct:76561198000000001:76561198000000002"; + +let unmount: (() => void) | undefined; + +beforeEach(() => { + useChatTabs().clearAll(); + useAuthStore().me = { steam_id: "76561198000000001" } as any; + + const { openTab } = useChatTabs(); + openTab({ + id: "matchmaking:lobby-1", + label: "Lobby", + instance: "matchmaking", + type: "matchmaking", + lobbyId: "lobby-1", + pinned: true, + activate: false, + }); + openTab({ + id: REQUESTED, + label: "Dana", + instance: "direct", + type: "direct", + lobbyId: "76561198000000001:76561198000000002", + }); +}); + +afterEach(() => { + unmount?.(); + unmount = undefined; + useAuthStore().me = undefined; + useChatTabs().clearAll(); +}); + +describe("ChatPanel", () => { + it("opens on the room already asked for rather than the first one", async () => { + const wrapper = await mountSuspended(ChatPanel, { + props: { isSidebarOpen: true, isTabActive: true }, + global: { stubs: { ChatLobby: true, ChatParticipants: true } }, + }); + unmount = () => wrapper.unmount(); + await flushPromises(); + + expect(useChatTabs().activeTabId.value).toBe(REQUESTED); + }); +}); diff --git a/tests/composables/useChatTabSetup.spec.ts b/tests/composables/useChatTabSetup.spec.ts new file mode 100644 index 000000000..2b67f261a --- /dev/null +++ b/tests/composables/useChatTabSetup.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, h, nextTick } from "vue"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import { useChatTabs } from "~/composables/useChatTabs"; +import { + tournamentChatTab, + useChatTabSetup, +} from "~/composables/useChatTabSetup"; +import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; +import { e_player_roles_enum } from "~/generated/zeus"; + +vi.mock("~/web-sockets/Socket", async (importOriginal) => ({ + ...(await importOriginal()), + default: { + listen: () => ({ stop() {} }), + joinLobby: () => ({ messages: [], on() {}, leave() {} }), + markLobbyRead() {}, + }, +})); + +const ME = "76561198000000001"; +const CHOSEN = "direct:76561198000000001:76561198000000002"; + +let unmount: (() => void) | undefined; + +async function mountSetup() { + const wrapper = await mountSuspended( + defineComponent({ + setup() { + useChatTabSetup(); + return () => h("div"); + }, + }), + ); + unmount = () => wrapper.unmount(); + await flushPromises(); +} + +function chooseRoom() { + useChatTabs().openTab({ + id: CHOSEN, + label: "Dana", + instance: "direct", + type: "direct", + lobbyId: "76561198000000001:76561198000000002", + }); +} + +beforeEach(() => { + useChatTabs().clearAll(); + useMatchLobbyStore().chatTournaments = []; + useMatchLobbyStore().myMatches = []; + useAuthStore().me = { + steam_id: ME, + current_lobby_id: "lobby-1", + role: e_player_roles_enum.administrator, + } as unknown as ReturnType["me"]; + vi.stubGlobal("$fetch", vi.fn().mockResolvedValue({ threads: [] })); +}); + +afterEach(() => { + unmount?.(); + unmount = undefined; + useAuthStore().me = undefined; + useMatchLobbyStore().chatTournaments = []; + useMatchLobbyStore().myMatches = []; + useChatTabs().clearAll(); + vi.unstubAllGlobals(); +}); + +describe("useChatTabSetup", () => { + it("opens the default rooms without selecting any of them", async () => { + useMatchLobbyStore().myMatches = [{ id: "match-1", label: "A vs B" }] as any; + + await mountSetup(); + + const { tabs, activeTabId } = useChatTabs(); + expect(tabs.value.map((tab) => tab.id)).toEqual( + expect.arrayContaining([ + "matchmaking:lobby-1", + "match:match-1", + "organizers", + ]), + ); + expect(activeTabId.value).toBeNull(); + }); + + it("keeps the chosen room when default rooms are added", async () => { + chooseRoom(); + + await mountSetup(); + + expect(useChatTabs().activeTabId.value).toBe(CHOSEN); + }); + + it("keeps the chosen room when a tournament room is added", async () => { + chooseRoom(); + await mountSetup(); + + useMatchLobbyStore().chatTournaments = [{ id: "t-1", name: "Cup" }]; + await nextTick(); + + const { tabs, activeTabId } = useChatTabs(); + expect(tabs.value).toContainEqual( + tournamentChatTab({ id: "t-1", name: "Cup" }), + ); + expect(activeTabId.value).toBe(CHOSEN); + }); +}); diff --git a/tests/composables/useIncomingDirectMessages.spec.ts b/tests/composables/useIncomingDirectMessages.spec.ts new file mode 100644 index 000000000..c08b5b201 --- /dev/null +++ b/tests/composables/useIncomingDirectMessages.spec.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, h } from "vue"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import { useChatTabs } from "~/composables/useChatTabs"; +import { useIncomingDirectMessages } from "~/composables/useIncomingDirectMessages"; +import { directTabId } from "~/composables/useDirectMessages"; +import { setActiveHub } from "~/composables/useHubState"; +import { useRightSidebar } from "~/composables/useRightSidebar"; + +const socketMock = vi.hoisted(() => { + const handlers = new Map void>>(); + + return { + handlers, + emit(event: string, data: unknown) { + for (const handler of handlers.get(event) ?? []) { + handler(data); + } + }, + }; +}); + +vi.mock("~/web-sockets/Socket", async (importOriginal) => ({ + ...(await importOriginal()), + default: { + listen(event: string, callback: (data: any) => void) { + if (!socketMock.handlers.has(event)) { + socketMock.handlers.set(event, new Set()); + } + socketMock.handlers.get(event)!.add(callback); + + return { + stop() { + socketMock.handlers.get(event)?.delete(callback); + }, + }; + }, + }, +})); + +const ME = "76561198000000001"; +const FRIEND = "76561198000000002"; +const ROOM = `${ME}:${FRIEND}`; +const TAB = directTabId(ROOM); + +let unmount: (() => void) | undefined; + +async function mountIncoming() { + const wrapper = await mountSuspended( + defineComponent({ + setup() { + useIncomingDirectMessages(); + return () => h("div"); + }, + }), + ); + unmount = () => wrapper.unmount(); + await flushPromises(); +} + +function incoming() { + socketMock.emit("direct:incoming", { + roomId: ROOM, + from: { steam_id: FRIEND, name: "Dana" }, + }); +} + +beforeEach(() => { + socketMock.handlers.clear(); + useChatTabs().clearAll(); + useAuthStore().me = { steam_id: ME } as any; + vi.stubGlobal("$fetch", vi.fn().mockResolvedValue({ conversations: [] })); +}); + +afterEach(() => { + unmount?.(); + unmount = undefined; + useAuthStore().me = undefined; + useChatTabs().clearAll(); + useRightSidebar().setRightSidebarOpen(false); + localStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe("useIncomingDirectMessages", () => { + it("badges the first message from someone not on the rail", async () => { + await mountIncoming(); + + incoming(); + + const { tabs, unreadCounts, activeTabId } = useChatTabs(); + expect(tabs.value.map((tab) => tab.id)).toContain(TAB); + expect(unreadCounts.value[TAB]).toBe(1); + expect(activeTabId.value).toBeNull(); + }); + + it("leaves an existing conversation to the room's own counter", async () => { + await mountIncoming(); + + incoming(); + incoming(); + + expect(useChatTabs().unreadCounts.value[TAB]).toBe(1); + }); + + it("does not badge a conversation that is on screen", async () => { + await mountIncoming(); + useChatTabs().setActiveTab(TAB); + setActiveHub("chat"); + useRightSidebar().setRightSidebarOpen(true); + + incoming(); + + expect(useChatTabs().unreadCounts.value[TAB] ?? 0).toBe(0); + }); +}); diff --git a/tests/composables/useSound.spec.ts b/tests/composables/useSound.spec.ts new file mode 100644 index 000000000..ffac2032e --- /dev/null +++ b/tests/composables/useSound.spec.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSound } from "~/composables/useSound"; + +let constructed = 0; + +class FakeAudioContext { + currentTime = 0; + destination = {}; + + constructor() { + constructed += 1; + } + + createOscillator() { + return { + type: "sine", + frequency: { value: 0 }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + } + + createGain() { + return { + connect: vi.fn(), + gain: { + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + }; + } +} + +beforeEach(() => { + constructed = 0; + vi.stubGlobal("AudioContext", FakeAudioContext); + vi.useFakeTimers({ toFake: ["setTimeout"] }); +}); + +afterEach(() => { + useSound().updateSettings(true); + vi.useRealTimers(); + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("useSound chat notification", () => { + it("plays both beeps while sounds are on", () => { + const { playNotificationSound } = useSound(); + + playNotificationSound(); + vi.runAllTimers(); + + expect(constructed).toBe(2); + }); + + it("stays silent once sounds are turned off", () => { + const { updateSettings, playNotificationSound } = useSound(); + + updateSettings(false); + playNotificationSound(); + vi.runAllTimers(); + + expect(constructed).toBe(0); + }); +}); From 458ac3505688b9ece9e0c86e0eb84d4ed0c41e76 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 15:33:01 -0400 Subject: [PATCH 2/8] bug: keep a hidden chat panel from clearing badges, chat room outside the tablist ChatPanel's auto-select also runs while the panel is mounted but hidden, and it cleared the selected room's unread -- which wiped the new first-DM badge whenever the panel had no room yet. Only a click clears a badge now; the on-screen watcher still clears it once the room is actually visible. The on-screen check in direct:incoming could never match a tab that does not exist yet, so it is gone. The tournament Chat Room button moves beside the TabsList instead of inside role=tablist. --- components/hub/ChatPanel.vue | 12 +- components/tournament/TournamentDetail.vue | 15 ++- composables/useIncomingDirectMessages.ts | 14 +- tests/components/ChatPanel.spec.ts | 123 ++++++++++++++---- .../useIncomingDirectMessages.spec.ts | 18 +-- 5 files changed, 124 insertions(+), 58 deletions(-) diff --git a/components/hub/ChatPanel.vue b/components/hub/ChatPanel.vue index 522854cf8..616bb6c7b 100644 --- a/components/hub/ChatPanel.vue +++ b/components/hub/ChatPanel.vue @@ -181,7 +181,7 @@ watch( () => props.isTabActive, (active) => { if (active && !activeChatId.value && orderedTabs.value.length > 0) { - handleSelectRoom(requestedOrFirstRoom(orderedTabs.value)); + selectRoom(requestedOrFirstRoom(orderedTabs.value)); } }, ); @@ -195,7 +195,7 @@ watch( activeChatId.value = next?.id ?? null; } if (!activeChatId.value && tabs.length > 0) { - handleSelectRoom(requestedOrFirstRoom(tabs)); + selectRoom(requestedOrFirstRoom(tabs)); } }, { immediate: true }, @@ -532,9 +532,15 @@ function removeConversation(roomId: string) { } } -function handleSelectRoom(tab: ChatTab) { +// The auto-select watchers also run while the panel is mounted but hidden, so +// they leave the badge to the on-screen watcher above; only a click clears it. +function selectRoom(tab: ChatTab) { activeChatId.value = tab.id; setActiveTab(tab.id); +} + +function handleSelectRoom(tab: ChatTab) { + selectRoom(tab); resetUnread(tab.id); } diff --git a/components/tournament/TournamentDetail.vue b/components/tournament/TournamentDetail.vue index 2293e5fa5..02827fe43 100644 --- a/components/tournament/TournamentDetail.vue +++ b/components/tournament/TournamentDetail.vue @@ -153,7 +153,12 @@ const tournamentHeroJoinButtonClasses = [ ]; const tournamentHeroSettingsButtonClasses = "h-9 w-9 border-[hsl(var(--tac-amber)_/_0.45)] bg-background/45 text-[hsl(var(--tac-amber))] hover:bg-[hsl(var(--tac-amber)_/_0.12)] hover:text-[hsl(var(--tac-amber))]"; -const tournamentHeroTabsClasses = "mt-5 border-t border-border pt-4"; +const tournamentHeroTabsClasses = + "mt-5 flex flex-wrap items-start gap-2 border-t border-border pt-4"; +const tournamentChatRoomStripClasses = [ + tacticalTabsListClasses, + "inline-flex items-center text-muted-foreground", +]; const tournamentChatRoomTabClasses = "relative z-[1] inline-flex items-center justify-center whitespace-nowrap rounded-md ring-offset-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"; const tournamentChatRoomUnreadClasses = @@ -587,8 +592,12 @@ function clearTeamEnterDelay(el: Element) { > {{ $t("tournament.notifications.title") }} + +
-
+ + From 3404b7e4057cea146653306d7189dd6d81fcdd49 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 17:44:51 -0400 Subject: [PATCH 4/8] style: keep the tournament chat room button beside the tabs on every width --- components/tournament/TournamentDetail.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/tournament/TournamentDetail.vue b/components/tournament/TournamentDetail.vue index fd15ac07a..63509005c 100644 --- a/components/tournament/TournamentDetail.vue +++ b/components/tournament/TournamentDetail.vue @@ -155,9 +155,9 @@ const tournamentHeroJoinButtonClasses = [ const tournamentHeroSettingsButtonClasses = "h-9 w-9 border-[hsl(var(--tac-amber)_/_0.45)] bg-background/45 text-[hsl(var(--tac-amber))] hover:bg-[hsl(var(--tac-amber)_/_0.12)] hover:text-[hsl(var(--tac-amber))]"; const tournamentHeroTabsClasses = - "mt-5 flex flex-wrap items-center gap-3 border-t border-border pt-4 md:flex-nowrap"; + "mt-5 flex items-start gap-3 border-t border-border pt-4"; const tournamentChatRoomButtonClasses = - "shrink-0 inline-flex items-center gap-2 font-sans text-[0.7rem] font-semibold uppercase leading-none tracking-[0.14em]"; + "shrink-0 inline-flex items-center gap-2 font-sans text-[0.7rem] font-semibold uppercase leading-none tracking-[0.14em] max-sm:px-2"; const tournamentChatRoomUnreadClasses = "inline-flex h-4 min-w-[1rem] origin-center items-center justify-center rounded-full bg-red-500 px-1 font-sans text-[0.6rem] font-bold leading-none tracking-normal text-white tabular-nums"; const chatRoomUnreadPopTransition = { @@ -498,7 +498,7 @@ function clearTeamEnterDelay(el: Element) {
{{ $t("tournament.overview") }} @@ -606,7 +606,7 @@ function clearTeamEnterDelay(el: Element) { @click="openChatRoom" > - + {{ $t("tournament.page.chat_room_tab") }} From 08aa2c5acf69ed2afbc4ee4cd9e62c3ac65e6f36 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:01:43 -0400 Subject: [PATCH 5/8] bug: close a chat tab back to its on-screen neighbour closeTab picked the next active tab by insertion order, so closing the active room could land on a tab far from it on the rail. The rail's sort (lobby, matches, channels by name, conversations by position) moves out of ChatPanel into orderChatTabs, and closeTab now falls back to the room after the closed one in that order, else the one before it. --- components/hub/ChatPanel.vue | 25 +------- composables/useChatTabs.ts | 9 ++- tests/composables/useChatTabs.spec.ts | 75 +++++++++++++++++++++++ tests/utilities/chatTabOrder.spec.ts | 86 +++++++++++++++++++++++++++ utilities/chatTabOrder.ts | 34 +++++++++++ 5 files changed, 201 insertions(+), 28 deletions(-) create mode 100644 tests/composables/useChatTabs.spec.ts create mode 100644 tests/utilities/chatTabOrder.spec.ts create mode 100644 utilities/chatTabOrder.ts diff --git a/components/hub/ChatPanel.vue b/components/hub/ChatPanel.vue index 616bb6c7b..6847ee5ad 100644 --- a/components/hub/ChatPanel.vue +++ b/components/hub/ChatPanel.vue @@ -18,6 +18,7 @@ import { useChatTabs, type ChatTab } from "~/composables/useChatTabs"; import { cancelChatTabRestore } from "~/composables/useChatTabPersistence"; import { useDirectConversationBar } from "~/composables/useDirectConversationBar"; import { directTabId } from "~/composables/useDirectMessages"; +import { orderChatTabs } from "~/utilities/chatTabOrder"; import { hapticTap } from "~/utilities/haptics"; import { ContextMenu, @@ -65,29 +66,7 @@ function teamLobbyIdFor(tab: ChatTab) { const activeChatId = ref(null); -const orderedTabs = computed(() => { - // Your own rooms first. Organizer and tournament rooms are broadcast - // channels, so landing on one by default put the least personal room in - // front of the lobby you are actually in. Conversations are not channels at - // all, so they sit below every channel behind a divider. - const weight = (tab: ChatTab) => { - if (tab.id.startsWith("matchmaking:")) return 0; - if (tab.type === "match") return 1; - if (tab.type === "direct") return 3; - return 2; - }; - return [...tabs.value].sort((a, b) => { - const wa = weight(a); - const wb = weight(b); - if (wa !== wb) return wa - wb; - // Conversations sit in the order the player dragged them into; channels - // are not arrangeable and stay alphabetical. - if (a.type === "direct" && b.type === "direct") { - return (a.position ?? 0) - (b.position ?? 0); - } - return a.label.localeCompare(b.label); - }); -}); +const orderedTabs = computed(() => orderChatTabs(tabs.value)); // The divider only earns its place when there are channels above it. const firstDirectTabId = computed(() => { diff --git a/composables/useChatTabs.ts b/composables/useChatTabs.ts index 4a521310e..393d4365e 100644 --- a/composables/useChatTabs.ts +++ b/composables/useChatTabs.ts @@ -1,5 +1,6 @@ import { ref, computed } from "vue"; import type { ChatType } from "~/web-sockets/Socket"; +import { orderChatTabs } from "~/utilities/chatTabOrder"; export interface ChatTab { id: string; @@ -71,15 +72,13 @@ export function useChatTabs() { return; } + const ordered = orderChatTabs(tabsRef.value); const [removed] = tabsRef.value.splice(idx, 1); delete unreadCountsRef.value[removed.id]; if (activeTabIdRef.value === removed.id) { - const next = - tabsRef.value[idx] || - tabsRef.value[idx - 1] || - tabsRef.value[0] || - null; + const position = ordered.findIndex((tab) => tab.id === removed.id); + const next = ordered[position + 1] ?? ordered[position - 1] ?? null; activeTabIdRef.value = next ? next.id : null; } } diff --git a/tests/composables/useChatTabs.spec.ts b/tests/composables/useChatTabs.spec.ts new file mode 100644 index 000000000..9dd1e8991 --- /dev/null +++ b/tests/composables/useChatTabs.spec.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useChatTabs, type ChatTab } from "~/composables/useChatTabs"; + +function open( + id: string, + type: ChatTab["type"], + label: string, + position?: number, +) { + useChatTabs().openTab({ + id, + label, + instance: type, + type, + lobbyId: id, + position, + activate: false, + }); +} + +beforeEach(() => { + useChatTabs().clearAll(); +}); + +afterEach(() => { + useChatTabs().clearAll(); +}); + +describe("useChatTabs closeTab", () => { + it("falls back to the room below it on the rail", () => { + open("direct:a", "direct", "Dana", 0); + open("organizers", "organizers", "Organizers"); + open("matchmaking:1", "matchmaking", "Lobby"); + open("match:1", "match", "A vs B"); + + const { closeTab, setActiveTab, activeTabId } = useChatTabs(); + setActiveTab("match:1"); + closeTab("match:1"); + + expect(activeTabId.value).toBe("organizers"); + }); + + it("falls back to the room above it when it was the last on the rail", () => { + open("direct:b", "direct", "Blake", 1); + open("matchmaking:1", "matchmaking", "Lobby"); + open("direct:a", "direct", "Alex", 0); + + const { closeTab, setActiveTab, activeTabId } = useChatTabs(); + setActiveTab("direct:b"); + closeTab("direct:b"); + + expect(activeTabId.value).toBe("direct:a"); + }); + + it("leaves nothing selected once the last room is closed", () => { + open("organizers", "organizers", "Organizers"); + + const { closeTab, setActiveTab, activeTabId } = useChatTabs(); + setActiveTab("organizers"); + closeTab("organizers"); + + expect(activeTabId.value).toBeNull(); + }); + + it("keeps the selected room when another one closes", () => { + open("organizers", "organizers", "Organizers"); + open("matchmaking:1", "matchmaking", "Lobby"); + + const { closeTab, setActiveTab, activeTabId } = useChatTabs(); + setActiveTab("organizers"); + closeTab("matchmaking:1"); + + expect(activeTabId.value).toBe("organizers"); + }); +}); diff --git a/tests/utilities/chatTabOrder.spec.ts b/tests/utilities/chatTabOrder.spec.ts new file mode 100644 index 000000000..c0ff488a8 --- /dev/null +++ b/tests/utilities/chatTabOrder.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { ChatTab } from "~/composables/useChatTabs"; +import { orderChatTabs } from "~/utilities/chatTabOrder"; + +const tab = ( + id: string, + type: ChatTab["type"], + label: string, + position?: number, +): ChatTab => ({ + id, + label, + instance: type, + type, + lobbyId: id, + pinned: false, + position, +}); + +const ids = (tabs: ChatTab[]) => tabs.map(({ id }) => id); + +describe("orderChatTabs", () => { + it("puts the lobby, then matches, then channels, then conversations", () => { + const tabs = [ + tab("direct:a", "direct", "Dana", 0), + tab("organizers", "organizers", "Organizers"), + tab("match:1", "match", "A vs B"), + tab("matchmaking:1", "matchmaking", "Lobby"), + ]; + + expect(ids(orderChatTabs(tabs))).toEqual([ + "matchmaking:1", + "match:1", + "organizers", + "direct:a", + ]); + }); + + it("sorts channels by label", () => { + const tabs = [ + tab("tournament:2", "tournament", "Winter Cup"), + tab("organizers", "organizers", "Organizers"), + tab("tournament:1", "tournament", "Autumn Cup"), + ]; + + expect(ids(orderChatTabs(tabs))).toEqual([ + "tournament:1", + "organizers", + "tournament:2", + ]); + }); + + it("sorts conversations by their rail position, not their name", () => { + const tabs = [ + tab("direct:a", "direct", "Alex", 2), + tab("direct:z", "direct", "Zoe", 0), + tab("direct:m", "direct", "Mia", 1), + ]; + + expect(ids(orderChatTabs(tabs))).toEqual([ + "direct:z", + "direct:m", + "direct:a", + ]); + }); + + it("puts a conversation with no position at the top of the rail", () => { + const tabs = [ + tab("direct:a", "direct", "Alex", 1), + tab("direct:b", "direct", "Blake"), + ]; + + expect(ids(orderChatTabs(tabs))).toEqual(["direct:b", "direct:a"]); + }); + + it("leaves the list it was given alone", () => { + const tabs = [ + tab("direct:a", "direct", "Dana", 0), + tab("matchmaking:1", "matchmaking", "Lobby"), + ]; + + orderChatTabs(tabs); + + expect(ids(tabs)).toEqual(["direct:a", "matchmaking:1"]); + }); +}); diff --git a/utilities/chatTabOrder.ts b/utilities/chatTabOrder.ts new file mode 100644 index 000000000..66f32f945 --- /dev/null +++ b/utilities/chatTabOrder.ts @@ -0,0 +1,34 @@ +import type { ChatTab } from "~/composables/useChatTabs"; + +// Your own rooms first. Organizer and tournament rooms are broadcast channels, +// so landing on one by default put the least personal room in front of the +// lobby you are actually in. Conversations are not channels at all, so they sit +// below every channel behind a divider. +function chatTabWeight(tab: ChatTab) { + if (tab.id.startsWith("matchmaking:")) { + return 0; + } + if (tab.type === "match") { + return 1; + } + if (tab.type === "direct") { + return 3; + } + return 2; +} + +export function orderChatTabs(tabs: readonly ChatTab[]): ChatTab[] { + return [...tabs].sort((a, b) => { + const wa = chatTabWeight(a); + const wb = chatTabWeight(b); + if (wa !== wb) { + return wa - wb; + } + // Conversations sit in the order the player dragged them into; channels + // are not arrangeable and stay alphabetical. + if (a.type === "direct" && b.type === "direct") { + return (a.position ?? 0) - (b.position ?? 0); + } + return a.label.localeCompare(b.label); + }); +} From c493c804349f4160a0c9e5bfd80cc5b1c32a55cc Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:03:01 -0400 Subject: [PATCH 6/8] style: keep tournament tabs left-aligned beside the chat room button flex-1 on the TabsList, with its base justify-center, centred the tabs across the whole row, which is every tournament without a chat room, and slid the triggers under the underline whenever the button's width changed. The list now sizes to its tabs and starts at the left, and the Chat Room button drops 0.2rem to sit level with the tab labels. --- components/tournament/TournamentDetail.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/tournament/TournamentDetail.vue b/components/tournament/TournamentDetail.vue index 63509005c..c1824bf8a 100644 --- a/components/tournament/TournamentDetail.vue +++ b/components/tournament/TournamentDetail.vue @@ -157,7 +157,7 @@ const tournamentHeroSettingsButtonClasses = const tournamentHeroTabsClasses = "mt-5 flex items-start gap-3 border-t border-border pt-4"; const tournamentChatRoomButtonClasses = - "shrink-0 inline-flex items-center gap-2 font-sans text-[0.7rem] font-semibold uppercase leading-none tracking-[0.14em] max-sm:px-2"; + "mt-[0.2rem] shrink-0 inline-flex items-center gap-2 font-sans text-[0.7rem] font-semibold uppercase leading-none tracking-[0.14em] max-sm:px-2"; const tournamentChatRoomUnreadClasses = "inline-flex h-4 min-w-[1rem] origin-center items-center justify-center rounded-full bg-red-500 px-1 font-sans text-[0.6rem] font-bold leading-none tracking-normal text-white tabular-nums"; const chatRoomUnreadPopTransition = { @@ -498,7 +498,7 @@ function clearTeamEnterDelay(el: Element) {
{{ $t("tournament.overview") }} From 9342c64e29cd10fb5c0dc0d53ea7f46b71f84035 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:07:25 -0400 Subject: [PATCH 7/8] bug: count every DM in a burst from a new contact direct:incoming only counted the message that created a conversation's tab. A second or third DM that landed before the new tab's room join reached neither path: the tab already existed, and the client wasn't in the room yet to get lobby:chat. The badge showed 1 until a reload. direct:incoming now counts for new and existing tabs alike, unless that conversation is on screen, using the same on-screen check as the room handler (isChatTabOnScreen). Both paths pass the message id to incrementUnread, and each badge remembers the ids it has counted, so a message that arrives both ways is counted once. That memory is capped at 200 ids and dropped when the badge is cleared or the tab closes. A non-zero recount from the server keeps it. --- composables/useChatTabSetup.ts | 19 +-- composables/useChatTabs.ts | 35 +++- composables/useIncomingDirectMessages.ts | 43 ++--- tests/composables/useChatTabs.spec.ts | 71 +++++++++ .../useIncomingDirectMessages.spec.ts | 149 ++++++++++++++++-- 5 files changed, 273 insertions(+), 44 deletions(-) diff --git a/composables/useChatTabSetup.ts b/composables/useChatTabSetup.ts index 004531441..1c68a84ac 100644 --- a/composables/useChatTabSetup.ts +++ b/composables/useChatTabSetup.ts @@ -21,18 +21,24 @@ export function tournamentChatTab(tournament: { }; } +export function isChatTabOnScreen(tabId: string) { + return ( + useChatTabs().activeTabId.value === tabId && + useRightSidebar().rightSidebarOpen.value && + currentHub() === "chat" + ); +} + export function useChatTabSetup() { const { t } = useI18n(); const { tabs, - activeTabId, openTab, closeTab, setPinned, incrementUnread, setUnread, } = useChatTabs(); - const { rightSidebarOpen } = useRightSidebar(); const { hydrate: hydrateReadState, unreadSince } = useChatReadState(); const matchLobbyStore = useMatchLobbyStore(); @@ -54,13 +60,8 @@ export function useChatTabSetup() { return; } - const isOnScreen = - activeTabId.value === tab.id && - rightSidebarOpen.value && - currentHub() === "chat"; - - if (!isOnScreen) { - incrementUnread(tab.id); + if (!isChatTabOnScreen(tab.id)) { + incrementUnread(tab.id, message?.id); } }); diff --git a/composables/useChatTabs.ts b/composables/useChatTabs.ts index 393d4365e..b246e1ff3 100644 --- a/composables/useChatTabs.ts +++ b/composables/useChatTabs.ts @@ -21,6 +21,10 @@ export interface ChatTab { const tabsRef = ref([]); const unreadCountsRef = ref>({}); +// A message can reach a badge twice, from direct:incoming and from its room's +// lobby:chat, so a badge remembers which ids it has already counted. +const unreadMessageIds = new Map>(); +const MAX_UNREAD_MESSAGE_IDS = 200; const activeTabIdRef = ref(null); export function useChatTabs() { @@ -75,6 +79,7 @@ export function useChatTabs() { const ordered = orderChatTabs(tabsRef.value); const [removed] = tabsRef.value.splice(idx, 1); delete unreadCountsRef.value[removed.id]; + unreadMessageIds.delete(removed.id); if (activeTabIdRef.value === removed.id) { const position = ordered.findIndex((tab) => tab.id === removed.id); @@ -109,23 +114,51 @@ export function useChatTabs() { }; } - function incrementUnread(id: string) { + function incrementUnread(id: string, messageId?: string) { + if (messageId) { + let counted = unreadMessageIds.get(id); + if (!counted) { + counted = new Set(); + unreadMessageIds.set(id, counted); + } + + if (counted.has(messageId)) { + return false; + } + + counted.add(messageId); + if (counted.size > MAX_UNREAD_MESSAGE_IDS) { + const [oldest] = counted; + counted.delete(oldest); + } + } + unreadCountsRef.value[id] = (unreadCountsRef.value[id] || 0) + 1; + return true; } function resetUnread(id: string) { + unreadMessageIds.delete(id); + if (unreadCountsRef.value[id]) { unreadCountsRef.value[id] = 0; } } + // A non-zero recount keeps the ids already counted, taking them to be part of + // the new number, so a late second delivery of one still isn't counted again. function setUnread(id: string, value: number) { unreadCountsRef.value[id] = value; + + if (!value) { + unreadMessageIds.delete(id); + } } function clearAll() { tabsRef.value = []; unreadCountsRef.value = {}; + unreadMessageIds.clear(); activeTabIdRef.value = null; } diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index 39f92d438..b81e11d0e 100644 --- a/composables/useIncomingDirectMessages.ts +++ b/composables/useIncomingDirectMessages.ts @@ -1,10 +1,11 @@ import { watch } from "vue"; -import socket from "~/web-sockets/Socket"; +import socket, { type LobbyMessage } from "~/web-sockets/Socket"; import { directTabId, peerSteamId, type DirectMessagePeer, } from "~/composables/useDirectMessages"; +import { isChatTabOnScreen } from "~/composables/useChatTabSetup"; // A conversation you haven't opened has no tab, so nothing is listening to its // room -- which is exactly the case for a first message from someone. The @@ -14,7 +15,7 @@ import { // Mounted once, from the default layout. export function useIncomingDirectMessages() { const authStore = useAuthStore(); - const { openTab, closeTab, setUnread, tabs } = useChatTabs(); + const { openTab, closeTab, incrementUnread, setUnread, tabs } = useChatTabs(); const { topPosition } = useDirectConversationBar(); function ensureTab( @@ -94,7 +95,11 @@ export function useIncomingDirectMessages() { socket.listen( "direct:incoming", - (data: { roomId: string; from: DirectMessagePeer }) => { + (data: { + roomId: string; + from: DirectMessagePeer; + message?: LobbyMessage; + }) => { const steamId = authStore.me?.steam_id; if (!steamId || !data?.roomId) { @@ -104,25 +109,21 @@ export function useIncomingDirectMessages() { // Deliberately does not inject the message: opening the tab makes // useChatTabSetup join the room, and the join's history snapshot delivers // it (deduped by chatMessageKey either way). - // - // Only a tab this creates is counted here. An existing tab is already in - // the room and its live lobby:chat handler counts the message, while the - // snapshot a new tab joins into is never counted for conversations. A new - // tab cannot be the one on screen; if ChatPanel selects it while visible, - // its on-screen watcher clears the badge. - const tabId = directTabId(data.roomId); - const isNew = !tabs.value.some((tab) => tab.id === tabId); + ensureTab(data.roomId, { + steam_id: + data.from?.steam_id ?? peerSteamId(data.roomId, steamId) ?? "", + name: data.from?.name, + avatar_url: data.from?.avatar_url, + }); - ensureTab( - data.roomId, - { - steam_id: - data.from?.steam_id ?? peerSteamId(data.roomId, steamId) ?? "", - name: data.from?.name, - avatar_url: data.from?.avatar_url, - }, - isNew ? 1 : 0, - ); + // Counted here for an existing tab too: a burst that lands before a new + // tab's join reaches no lobby:chat, and the join's snapshot is never + // counted for conversations. Once joined, the room's lobby:chat counts + // the same message, so both go by its id. + const tabId = directTabId(data.roomId); + if (!isChatTabOnScreen(tabId)) { + incrementUnread(tabId, data.message?.id); + } }, ); diff --git a/tests/composables/useChatTabs.spec.ts b/tests/composables/useChatTabs.spec.ts index 9dd1e8991..d2ca79306 100644 --- a/tests/composables/useChatTabs.spec.ts +++ b/tests/composables/useChatTabs.spec.ts @@ -73,3 +73,74 @@ describe("useChatTabs closeTab", () => { expect(activeTabId.value).toBe("organizers"); }); }); + +describe("useChatTabs incrementUnread", () => { + const TAB = "organizers"; + + const count = () => useChatTabs().unreadCounts.value[TAB] ?? 0; + + it("counts a message id once", () => { + const { incrementUnread } = useChatTabs(); + + expect(incrementUnread(TAB, "m1")).toBe(true); + expect(incrementUnread(TAB, "m1")).toBe(false); + expect(incrementUnread(TAB, "m2")).toBe(true); + + expect(count()).toBe(2); + }); + + it("counts a message with no id every time", () => { + const { incrementUnread } = useChatTabs(); + + incrementUnread(TAB); + incrementUnread(TAB); + + expect(count()).toBe(2); + }); + + it("keeps what it counted through a non-zero recount", () => { + const { incrementUnread, setUnread } = useChatTabs(); + + incrementUnread(TAB, "m1"); + setUnread(TAB, 4); + + expect(incrementUnread(TAB, "m1")).toBe(false); + expect(incrementUnread(TAB, "m2")).toBe(true); + expect(count()).toBe(5); + }); + + it("forgets what it counted once the badge is cleared", () => { + const { incrementUnread, resetUnread, setUnread } = useChatTabs(); + + incrementUnread(TAB, "m1"); + resetUnread(TAB); + expect(incrementUnread(TAB, "m1")).toBe(true); + + setUnread(TAB, 0); + expect(incrementUnread(TAB, "m1")).toBe(true); + + expect(count()).toBe(1); + }); + + it("forgets what it counted once the tab is closed", () => { + open(TAB, "organizers", "Organizers"); + const { incrementUnread, closeTab } = useChatTabs(); + + incrementUnread(TAB, "m1"); + closeTab(TAB); + open(TAB, "organizers", "Organizers"); + + expect(incrementUnread(TAB, "m1")).toBe(true); + }); + + it("remembers only the latest 200 ids", () => { + const { incrementUnread } = useChatTabs(); + + for (let index = 0; index <= 200; index++) { + incrementUnread(TAB, `m${index}`); + } + + expect(incrementUnread(TAB, "m1")).toBe(false); + expect(incrementUnread(TAB, "m0")).toBe(true); + }); +}); diff --git a/tests/composables/useIncomingDirectMessages.spec.ts b/tests/composables/useIncomingDirectMessages.spec.ts index b6b43b49a..b2d5f2acd 100644 --- a/tests/composables/useIncomingDirectMessages.spec.ts +++ b/tests/composables/useIncomingDirectMessages.spec.ts @@ -3,19 +3,29 @@ import { defineComponent, h } from "vue"; import { flushPromises } from "@vue/test-utils"; import { mountSuspended } from "@nuxt/test-utils/runtime"; import { useChatTabs } from "~/composables/useChatTabs"; +import { useChatTabSetup } from "~/composables/useChatTabSetup"; import { useIncomingDirectMessages } from "~/composables/useIncomingDirectMessages"; import { directTabId } from "~/composables/useDirectMessages"; +import { useRightSidebar } from "~/composables/useRightSidebar"; +import { setActiveHub } from "~/composables/useHubState"; const socketMock = vi.hoisted(() => { const handlers = new Map void>>(); + const lobbies = new Map void>>>(); return { handlers, + lobbies, emit(event: string, data: unknown) { for (const handler of handlers.get(event) ?? []) { handler(data); } }, + emitLobby(room: string, event: string, data: unknown) { + for (const handler of lobbies.get(room)?.get(event) ?? []) { + handler(data); + } + }, }; }); @@ -34,6 +44,25 @@ vi.mock("~/web-sockets/Socket", async (importOriginal) => ({ }, }; }, + joinLobby(_instance: string, type: string, id: string) { + const room = `${type}:${id}`; + const events = new Map void>>(); + socketMock.lobbies.set(room, events); + + return { + messages: [], + on(event: string, callback: (data: any) => void) { + if (!events.has(event)) { + events.set(event, new Set()); + } + events.get(event)!.add(callback); + }, + leave() { + socketMock.lobbies.delete(room); + }, + }; + }, + markLobbyRead() {}, }, })); @@ -44,10 +73,11 @@ const TAB = directTabId(ROOM); let unmount: (() => void) | undefined; -async function mountIncoming() { +async function mountChat() { const wrapper = await mountSuspended( defineComponent({ setup() { + useChatTabSetup(); useIncomingDirectMessages(); return () => h("div"); }, @@ -57,23 +87,59 @@ async function mountIncoming() { await flushPromises(); } -function incoming() { +function stubApi(conversations: unknown[] = []) { + vi.stubGlobal( + "$fetch", + vi.fn((url: string) => + Promise.resolve( + url.endsWith("/chat/direct/conversations") + ? { conversations } + : { threads: [] }, + ), + ), + ); +} + +const line = (id: string) => ({ + id, + message: `line ${id}`, + timestamp: new Date().toISOString(), + from: { steam_id: FRIEND, name: "Dana" }, +}); + +function incoming(id: string) { socketMock.emit("direct:incoming", { roomId: ROOM, from: { steam_id: FRIEND, name: "Dana" }, + message: line(id), }); } +function roomChat(id: string) { + expect(socketMock.lobbies.has(`direct:${ROOM}`)).toBe(true); + socketMock.emitLobby(`direct:${ROOM}`, "lobby:chat", line(id)); +} + +function showConversation(onScreen: boolean) { + useChatTabs().setActiveTab(onScreen ? TAB : null); + useRightSidebar().setRightSidebarOpen(onScreen); + setActiveHub(onScreen ? "chat" : "social"); +} + +const unread = () => useChatTabs().unreadCounts.value[TAB] ?? 0; + beforeEach(() => { socketMock.handlers.clear(); + socketMock.lobbies.clear(); useChatTabs().clearAll(); useAuthStore().me = { steam_id: ME } as any; - vi.stubGlobal("$fetch", vi.fn().mockResolvedValue({ conversations: [] })); + stubApi(); }); afterEach(() => { unmount?.(); unmount = undefined; + showConversation(false); useAuthStore().me = undefined; useChatTabs().clearAll(); vi.unstubAllGlobals(); @@ -81,23 +147,80 @@ afterEach(() => { describe("useIncomingDirectMessages", () => { it("badges the first message from someone not on the rail", async () => { - await mountIncoming(); + await mountChat(); - incoming(); + incoming("m1"); - const { tabs, unreadCounts, activeTabId } = useChatTabs(); + const { tabs, activeTabId } = useChatTabs(); expect(tabs.value.map((tab) => tab.id)).toContain(TAB); - expect(unreadCounts.value[TAB]).toBe(1); + expect(unread()).toBe(1); expect(activeTabId.value).toBeNull(); }); - it("leaves an existing conversation to the room's own counter", async () => { - await mountIncoming(); + it("counts every message in a burst that lands before the room is joined", async () => { + await mountChat(); + + incoming("m1"); + incoming("m2"); + incoming("m3"); + + expect(unread()).toBe(3); + }); + + it("counts a message once when the joined room delivers it too", async () => { + await mountChat(); + + incoming("m1"); + await flushPromises(); + roomChat("m1"); + + expect(unread()).toBe(1); + + roomChat("m2"); + incoming("m2"); + + expect(unread()).toBe(2); + }); + + it("leaves the conversation on screen alone", async () => { + await mountChat(); + + incoming("m1"); + await flushPromises(); + useChatTabs().resetUnread(TAB); + + showConversation(true); + incoming("m2"); + roomChat("m2"); + + expect(unread()).toBe(0); + + showConversation(false); + incoming("m3"); + + expect(unread()).toBe(1); + }); + + it("adds a live message to the count the server hydrated", async () => { + stubApi([ + { + roomId: ROOM, + unread: 4, + isOpen: true, + position: 0, + peer: { steam_id: FRIEND, name: "Dana" }, + }, + ]); + await mountChat(); + + expect(unread()).toBe(4); + + incoming("m5"); + + expect(unread()).toBe(5); - incoming(); - useChatTabs().setUnread(TAB, 4); - incoming(); + roomChat("m5"); - expect(useChatTabs().unreadCounts.value[TAB]).toBe(4); + expect(unread()).toBe(5); }); }); From 43286b409dc0e55182e4507a49f50b87de4ee840 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 20:47:07 -0400 Subject: [PATCH 8/8] test: prove the tournament chat room button stays beside left-aligned tabs --- .../tournament/TournamentChatRoom.spec.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/components/tournament/TournamentChatRoom.spec.ts diff --git a/tests/components/tournament/TournamentChatRoom.spec.ts b/tests/components/tournament/TournamentChatRoom.spec.ts new file mode 100644 index 000000000..341c56eb3 --- /dev/null +++ b/tests/components/tournament/TournamentChatRoom.spec.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { config, flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import TournamentDetail from "~/components/tournament/TournamentDetail.vue"; +import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; + +const tournament = { + id: "t-1", + name: "Spring Cup", + status: "Live", + e_tournament_status: { value: "Live", description: "Live" }, + options: { type: "Competitive", mr: 12, best_of: 1 }, + admin: { steam_id: "1", name: "Organizer", avatar_url: null }, + teams: [], + stages: [], + rosters: [], + organizers: [], +}; + +let mounted: { unmount: () => void } | null = null; + +async function mountDetail(inChat: boolean) { + useMatchLobbyStore().chatTournaments = inChat + ? [{ id: tournament.id, name: tournament.name }] + : []; + + const wrapper = await mountSuspended(TournamentDetail, { + route: `/tournaments/${tournament.id}`, + shallow: true, + }); + mounted = wrapper; + (wrapper.vm as any).tournamentLive = { ...tournament }; + await flushPromises(); + return wrapper; +} + +function classesOf(element: { attributes: (name: string) => string }) { + return (element.attributes("class") ?? "").split(/\s+/); +} + +beforeEach(() => { + config.global.renderStubDefaultSlot = true; +}); + +afterEach(() => { + mounted?.unmount(); + mounted = null; + useMatchLobbyStore().chatTournaments = []; + config.global.renderStubDefaultSlot = false; +}); + +describe("tournament chat room button", () => { + it("is left out for a tournament without a chat room", async () => { + const wrapper = await mountDetail(false); + + expect(wrapper.find("tabs-list-stub").exists()).toBe(true); + expect(wrapper.text()).not.toContain("Chat Room"); + }); + + it("sits beside the left-aligned tabs on one row at every width", async () => { + const wrapper = await mountDetail(true); + const tabList = wrapper.find("tabs-list-stub"); + const row = tabList.element.parentElement!; + const button = tabList.element.nextElementSibling!; + + expect(button.tagName.toLowerCase()).toBe("button-stub"); + expect(button.textContent).toContain("Chat Room"); + + const rowClasses = row.className.split(/\s+/); + expect(rowClasses).toEqual(expect.arrayContaining(["flex", "items-start"])); + expect(rowClasses).not.toContain("flex-wrap"); + + const tabListClasses = classesOf(tabList); + expect(tabListClasses).toEqual( + expect.arrayContaining(["min-w-0", "flex-wrap", "justify-start"]), + ); + expect(tabListClasses).not.toContain("flex-1"); + + expect(button.className.split(/\s+/)).toEqual( + expect.arrayContaining(["shrink-0", "max-sm:px-2"]), + ); + + const label = [...button.querySelectorAll("span")].find((span) => + span.textContent?.includes("Chat Room"), + )!; + expect(label.className.split(/\s+/)).toEqual( + expect.arrayContaining(["sr-only", "sm:not-sr-only"]), + ); + }); +});