diff --git a/components/hub/ChatPanel.vue b/components/hub/ChatPanel.vue index 3a687706a..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(() => { @@ -169,12 +148,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]); + selectRoom(requestedOrFirstRoom(orderedTabs.value)); } }, ); @@ -188,7 +174,7 @@ watch( activeChatId.value = next?.id ?? null; } if (!activeChatId.value && tabs.length > 0) { - handleSelectRoom(tabs[0]); + selectRoom(requestedOrFirstRoom(tabs)); } }, { immediate: true }, @@ -525,9 +511,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 7cdb01a63..c1824bf8a 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"; @@ -86,6 +87,7 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; +import AnimatedStat from "~/components/AnimatedStat.vue"; import PageTransition from "~/components/ui/transitions/PageTransition.vue"; import FadeSwap from "~/components/ui/transitions/FadeSwap.vue"; import HeightMorph from "~/components/ui/transitions/HeightMorph.vue"; @@ -152,7 +154,20 @@ 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 items-start gap-3 border-t border-border pt-4"; +const tournamentChatRoomButtonClasses = + "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 = { + enterActiveClass: + "[transition:transform_0.3s_cubic-bezier(0.34,1.56,0.64,1),opacity_0.2s_ease] motion-reduce:[transition:none]", + enterFromClass: "scale-0 opacity-0", + leaveActiveClass: + "[transition:transform_0.15s_ease-in,opacity_0.15s_ease-in] motion-reduce:[transition:none]", + leaveToClass: "scale-0 opacity-0", +}; 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 = @@ -483,7 +498,7 @@ function clearTeamEnterDelay(el: Element) {
{{ $t("tournament.overview") }} @@ -583,6 +598,26 @@ function clearTeamEnterDelay(el: Element) { {{ $t("tournament.notifications.title") }} +
@@ -1141,6 +1176,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 +1888,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 +2230,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..1c68a84ac 100644 --- a/composables/useChatTabSetup.ts +++ b/composables/useChatTabSetup.ts @@ -1,25 +1,44 @@ 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 isChatTabOnScreen(tabId: string) { + return ( + useChatTabs().activeTabId.value === tabId && + useRightSidebar().rightSidebarOpen.value && + currentHub() === "chat" + ); +} + export function useChatTabSetup() { const { t } = useI18n(); const { tabs, - activeTabId, openTab, closeTab, - setActiveTab, setPinned, incrementUnread, setUnread, } = useChatTabs(); - const { rightSidebarOpen } = useRightSidebar(); const { hydrate: hydrateReadState, unreadSince } = useChatReadState(); const matchLobbyStore = useMatchLobbyStore(); @@ -41,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); } }); @@ -104,25 +118,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 +139,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 +151,7 @@ export function useChatTabSetup() { type: "matchmaking", lobbyId: me.current_lobby_id, pinned: true, + activate: false, }); } } @@ -164,6 +170,7 @@ export function useChatTabSetup() { type: "match", lobbyId: currentMatch.id, pinned: true, + activate: false, }); } } @@ -180,6 +187,7 @@ export function useChatTabSetup() { type: "organizers", lobbyId: organizerId, pinned: true, + activate: false, }); } else if (!existing.pinned) { setPinned(organizerId, true); @@ -187,14 +195,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..b246e1ff3 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; @@ -20,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() { @@ -31,16 +36,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 +57,8 @@ export function useChatTabs() { } const tab: ChatTab = { - ...payload, - pinned: payload.pinned ?? false, + ...fields, + pinned: fields.pinned ?? false, }; tabsRef.value.push(tab); @@ -70,15 +76,14 @@ export function useChatTabs() { return; } + 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 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; } } @@ -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 c1a14ab18..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) { @@ -110,6 +115,15 @@ export function useIncomingDirectMessages() { name: data.from?.name, avatar_url: data.from?.avatar_url, }); + + // 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/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..221a580a3 --- /dev/null +++ b/tests/components/ChatPanel.spec.ts @@ -0,0 +1,130 @@ +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 ChatPanel from "~/components/hub/ChatPanel.vue"; +import { useChatTabs } from "~/composables/useChatTabs"; +import { useIncomingDirectMessages } from "~/composables/useIncomingDirectMessages"; +import { directTabId } from "~/composables/useDirectMessages"; + +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); + }, + }; + }, + joinLobby: () => ({ messages: [], on() {}, leave() {} }), + markLobbyRead() {}, + }, +})); + +const ME = "76561198000000001"; +const FRIEND = "76561198000000002"; +const ROOM = `${ME}:${FRIEND}`; +const DIRECT = directTabId(ROOM); + +let unmount: (() => void) | undefined; + +async function mountPanel(visible: boolean) { + const wrapper = await mountSuspended( + defineComponent({ + setup() { + useIncomingDirectMessages(); + return () => + h(ChatPanel, { isSidebarOpen: visible, isTabActive: visible }); + }, + }), + { global: { stubs: { ChatLobby: true, ChatParticipants: true } } }, + ); + unmount = () => wrapper.unmount(); + await flushPromises(); +} + +function incoming() { + socketMock.emit("direct:incoming", { + roomId: ROOM, + from: { steam_id: FRIEND }, + }); +} + +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(); + vi.unstubAllGlobals(); +}); + +describe("ChatPanel", () => { + it("opens on the room already asked for rather than the first one", async () => { + const { openTab } = useChatTabs(); + openTab({ + id: "matchmaking:lobby-1", + label: "Lobby", + instance: "matchmaking", + type: "matchmaking", + lobbyId: "lobby-1", + pinned: true, + activate: false, + }); + openTab({ + id: DIRECT, + label: "Dana", + instance: "direct", + type: "direct", + lobbyId: ROOM, + }); + + await mountPanel(true); + + expect(useChatTabs().activeTabId.value).toBe(DIRECT); + }); + + it("keeps a first message's badge while the panel is hidden", async () => { + await mountPanel(false); + + incoming(); + await nextTick(); + + expect(useChatTabs().unreadCounts.value[DIRECT]).toBe(1); + }); + + it("clears a first message's badge once the panel shows it", async () => { + await mountPanel(true); + + incoming(); + await nextTick(); + + expect(useChatTabs().activeTabId.value).toBe(DIRECT); + expect(useChatTabs().unreadCounts.value[DIRECT]).toBe(0); + }); +}); 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"]), + ); + }); +}); 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/useChatTabs.spec.ts b/tests/composables/useChatTabs.spec.ts new file mode 100644 index 000000000..d2ca79306 --- /dev/null +++ b/tests/composables/useChatTabs.spec.ts @@ -0,0 +1,146 @@ +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"); + }); +}); + +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 new file mode 100644 index 000000000..b2d5f2acd --- /dev/null +++ b/tests/composables/useIncomingDirectMessages.spec.ts @@ -0,0 +1,226 @@ +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 { 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); + } + }, + }; +}); + +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); + }, + }; + }, + 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() {}, + }, +})); + +const ME = "76561198000000001"; +const FRIEND = "76561198000000002"; +const ROOM = `${ME}:${FRIEND}`; +const TAB = directTabId(ROOM); + +let unmount: (() => void) | undefined; + +async function mountChat() { + const wrapper = await mountSuspended( + defineComponent({ + setup() { + useChatTabSetup(); + useIncomingDirectMessages(); + return () => h("div"); + }, + }), + ); + unmount = () => wrapper.unmount(); + await flushPromises(); +} + +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; + stubApi(); +}); + +afterEach(() => { + unmount?.(); + unmount = undefined; + showConversation(false); + useAuthStore().me = undefined; + useChatTabs().clearAll(); + vi.unstubAllGlobals(); +}); + +describe("useIncomingDirectMessages", () => { + it("badges the first message from someone not on the rail", async () => { + await mountChat(); + + incoming("m1"); + + const { tabs, activeTabId } = useChatTabs(); + expect(tabs.value.map((tab) => tab.id)).toContain(TAB); + expect(unread()).toBe(1); + expect(activeTabId.value).toBeNull(); + }); + + 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); + + roomChat("m5"); + + expect(unread()).toBe(5); + }); +}); 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); + }); +}); 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); + }); +}