Skip to content
Merged
44 changes: 18 additions & 26 deletions components/hub/ChatPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,29 +66,7 @@ function teamLobbyIdFor(tab: ChatTab) {

const activeChatId = ref<string | null>(null);

const orderedTabs = computed<ChatTab[]>(() => {
// 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<ChatTab[]>(() => orderChatTabs(tabs.value));

// The divider only earns its place when there are channels above it.
const firstDirectTabId = computed(() => {
Expand Down Expand Up @@ -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));
}
},
);
Expand All @@ -188,7 +174,7 @@ watch(
activeChatId.value = next?.id ?? null;
}
if (!activeChatId.value && tabs.length > 0) {
handleSelectRoom(tabs[0]);
selectRoom(requestedOrFirstRoom(tabs));
}
},
{ immediate: true },
Expand Down Expand Up @@ -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);
}

Expand Down
82 changes: 80 additions & 2 deletions components/tournament/TournamentDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -483,7 +498,7 @@ function clearTeamEnterDelay(el: Element) {
<div :class="tournamentHeroTabsClasses">
<TabsList
variant="underline"
:class="[tacticalTabsListClasses, 'h-auto flex-wrap']"
:class="[tacticalTabsListClasses, 'h-auto min-w-0 flex-wrap justify-start']"
>
<TabsTrigger value="overview" :class="tacticalTabsTriggerClasses">
{{ $t("tournament.overview") }}
Expand Down Expand Up @@ -583,6 +598,26 @@ function clearTeamEnterDelay(el: Element) {
{{ $t("tournament.notifications.title") }}
</TabsTrigger>
</TabsList>
<Button
v-if="chatRoomTournament"
variant="ghost"
size="sm"
:class="tournamentChatRoomButtonClasses"
@click="openChatRoom"
>
<MessageSquare class="h-4 w-4 shrink-0" />
<span class="sr-only leading-none sm:not-sr-only">
{{ $t("tournament.page.chat_room_tab") }}
</span>
<Transition v-bind="chatRoomUnreadPopTransition">
<span
v-if="chatRoomUnreadLabel"
:class="tournamentChatRoomUnreadClasses"
>
<AnimatedStat :value="chatRoomUnreadLabel" />
</span>
</Transition>
</Button>
</div>
</header>
</PageTransition>
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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();
},
Expand Down
66 changes: 33 additions & 33 deletions composables/useChatTabSetup.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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);
}
});

Expand Down Expand Up @@ -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)) {
Expand All @@ -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}`;
Expand All @@ -146,6 +151,7 @@ export function useChatTabSetup() {
type: "matchmaking",
lobbyId: me.current_lobby_id,
pinned: true,
activate: false,
});
}
}
Expand All @@ -164,6 +170,7 @@ export function useChatTabSetup() {
type: "match",
lobbyId: currentMatch.id,
pinned: true,
activate: false,
});
}
}
Expand All @@ -180,21 +187,14 @@ export function useChatTabSetup() {
type: "organizers",
lobbyId: organizerId,
pinned: true,
activate: false,
});
} else if (!existing.pinned) {
setPinned(organizerId, true);
}
} 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(
Expand Down
Loading
Loading