Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions components/chat/ChatLobby.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import ChatMessages from "~/components/chat/ChatMessages.vue";
import ChatInput from "~/components/chat/ChatInput.vue";
import ChatMatchHeader from "~/components/chat/ChatMatchHeader.vue";
import Empty from "~/components/ui/empty/Empty.vue";
import { Volume2, VolumeX } from "lucide-vue-next";

defineOptions({
inheritAttrs: false,
});
</script>

<template>
Expand Down Expand Up @@ -77,6 +82,16 @@ import Empty from "~/components/ui/empty/Empty.vue";
{{ participantsCount }} in chat
</button>
</div>
<button
type="button"
class="inline-flex h-6 w-6 items-center justify-center rounded border border-border/50 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
:aria-label="chatSoundToggleLabel"
:title="chatSoundToggleLabel"
@click.stop="toggleChatSound"
>
<Volume2 v-if="chatSoundActive" class="h-3.5 w-3.5" />
<VolumeX v-else class="h-3.5 w-3.5" />
</button>
</div>
<div
v-if="showParticipants"
Expand Down Expand Up @@ -188,6 +203,16 @@ import Empty from "~/components/ui/empty/Empty.vue";
{{ participantsCount }} in chat
</button>
</div>
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded border border-border/50 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
:aria-label="chatSoundToggleLabel"
:title="chatSoundToggleLabel"
@click.stop="toggleChatSound"
>
<Volume2 v-if="chatSoundActive" class="h-3.5 w-3.5" />
<VolumeX v-else class="h-3.5 w-3.5" />
</button>
<NuxtLink
v-if="isGlobalContext && matchInfo"
:to="`/matches/${(matchInfo as any).id}`"
Expand Down Expand Up @@ -293,7 +318,12 @@ import { useSound } from "~/composables/useSound";
import { useMatchLobbyStore } from "~/stores/MatchLobbyStore";

const { rightSidebarOpen } = useRightSidebar();
const { playNotificationSound } = useSound();
const {
isEnabled: chatSoundState,
isAutoMutedForInGame: chatSoundAutoMutedForInGame,
playNotificationSound: playChatNotificationSound,
updateSettings: updateSoundSettings,
} = useSound();

interface ChatMessagesRef {
scrollToBottom: (force?: boolean) => void;
Expand Down Expand Up @@ -397,6 +427,24 @@ export default {
participantsCount() {
return this.participants.length;
},
chatSoundEnabled() {
return chatSoundState.value;
},
chatSoundActive() {
return chatSoundState.value && !chatSoundAutoMutedForInGame.value;
},
chatSoundToggleLabel() {
if (chatSoundAutoMutedForInGame.value) {
return this.$t(
"ui_extras.auto_muted_in_game",
"Auto-muted while in game",
);
}

return chatSoundState.value
? this.$t("ui_extras.mute")
: this.$t("ui_extras.unmute");
},
matchInfo() {
if (this.type !== "match") {
return null;
Expand Down Expand Up @@ -510,6 +558,9 @@ export default {
direction: "outbound",
});
},
toggleChatSound() {
updateSoundSettings(!chatSoundState.value);
},
handleBottomStateChange(atBottom: boolean) {
this.isAtBottom = atBottom;
},
Expand Down Expand Up @@ -583,7 +634,7 @@ export default {
this.lastReadMessageCount = this.messages.length;
}
if (this.playNotificationSound && !isOwnMessage) {
playNotificationSound();
playChatNotificationSound();
}

this.$emit("message-received", {
Expand Down
43 changes: 43 additions & 0 deletions components/hub/ChatPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
Shield,
MessageSquare,
ExternalLink,
Volume2,
VolumeX,
} from "lucide-vue-next";
import { useRouter } from "#app";
import ChatLobby from "~/components/chat/ChatLobby.vue";
Expand All @@ -17,6 +19,7 @@ import TooltipProvider from "~/components/ui/tooltip/TooltipProvider.vue";
import TooltipTrigger from "~/components/ui/tooltip/TooltipTrigger.vue";
import TooltipContent from "~/components/ui/tooltip/TooltipContent.vue";
import { useMatchLobbyStore } from "~/stores/MatchLobbyStore";
import { useSound } from "~/composables/useSound";

const props = defineProps<{
isSidebarOpen: boolean;
Expand All @@ -30,6 +33,11 @@ const { tabs, unreadCounts, setActiveTab, resetUnread, incrementUnread } =
useChatTabs();

const matchLobbyStore = useMatchLobbyStore();
const {
isEnabled: chatSoundEnabled,
isAutoMutedForInGame: chatSoundAutoMutedForInGame,
updateSettings: updateSoundSettings,
} = useSound();
const isMobile = useMediaQuery("(max-width: 768px)");

const activeChatId = ref<string | null>(null);
Expand Down Expand Up @@ -65,6 +73,18 @@ const activeParticipantsCount = computed(() => {
return map.size;
});

const chatSoundActive = computed(
() => chatSoundEnabled.value && !chatSoundAutoMutedForInGame.value,
);

const chatSoundToggleLabel = computed(() => {
if (chatSoundAutoMutedForInGame.value) {
return t("ui_extras.auto_muted_in_game", "Auto-muted while in game");
}

return chatSoundEnabled.value ? t("ui_extras.mute") : t("ui_extras.unmute");
});

const activeParticipants = computed<
{ steam_id: string; name: string; avatar_url?: string }[]
>(() => {
Expand Down Expand Up @@ -224,6 +244,10 @@ function handlePopOut() {
].join(",");
window.open(route.href, "_blank", features);
}

function toggleChatSound() {
updateSoundSettings(!chatSoundEnabled.value);
}
</script>

<template>
Expand Down Expand Up @@ -351,6 +375,25 @@ function handlePopOut() {
</div>
<div class="flex items-center gap-1.5">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card/50 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
:aria-label="chatSoundToggleLabel"
@click="toggleChatSound"
>
<Volume2 v-if="chatSoundActive" class="w-3.5 h-3.5" />
<VolumeX v-else class="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
class="bg-zinc-900 text-zinc-50 border border-zinc-800 shadow-lg rounded-md px-3 py-1.5 text-[11px]"
>
{{ chatSoundToggleLabel }}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
Expand Down
43 changes: 33 additions & 10 deletions composables/useSound.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { ref } from "vue";
import { computed, readonly, ref } from "vue";
import { useAuthStore } from "~/stores/AuthStore";
import { useMatchLobbyStore } from "~/stores/MatchLobbyStore";
import { shouldAutoMuteChatSound } from "~/utilities/chatSoundAutoMute";

const isEnabled = ref(true);
const volume = ref(0.7);
let settingsLoaded = false;

export const useSound = () => {
const isEnabled = ref(true);
const volume = ref(0.7);
const isAutoMutedForInGame = computed(() => {
if (!import.meta.client) {
return false;
}

const matchLobbyStore = useMatchLobbyStore();

return shouldAutoMuteChatSound(
useAuthStore().me?.steam_id,
(matchLobbyStore.myMatches as unknown as any[]) || [],
matchLobbyStore.lobbyChat,
);
});

const loadSettings = () => {
if (!import.meta.client) {
return;
}

if (settingsLoaded) {
return;
}

const savedEnabled = localStorage.getItem("chat-sound-enabled");
const savedVolume = localStorage.getItem("chat-sound-volume");

Expand All @@ -19,6 +40,8 @@ export const useSound = () => {
if (savedVolume !== null) {
volume.value = parseFloat(savedVolume);
}

settingsLoaded = true;
};

const saveSettings = () => {
Expand All @@ -31,12 +54,7 @@ export const useSound = () => {
};

const isInGame = () => {
if (!import.meta.client) return false;
try {
return useMatchLobbyStore().currentUserInGame;
} catch {
return false;
}
return isAutoMutedForInGame.value;
};

const generateBeepSound = (
Expand Down Expand Up @@ -75,14 +93,18 @@ export const useSound = () => {
};

const playNotificationSound = () => {
if (isInGame()) {
if (!import.meta.client || !isEnabled.value || isInGame()) {
return;
}

generateBeepSound(800, 200);

// Add a second beep for a more distinctive notification
setTimeout(() => {
if (!isEnabled.value || isAutoMutedForInGame.value) {
return;
}

generateBeepSound(600, 150);
}, 100);
};
Expand Down Expand Up @@ -446,5 +468,6 @@ export const useSound = () => {
playCountdownSound,
volume: readonly(volume),
isEnabled: readonly(isEnabled),
isAutoMutedForInGame: readonly(isAutoMutedForInGame),
};
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"postinstall": "nuxt prepare",
"codegen": "bash -c '. ./.env && zeus https://$NUXT_PUBLIC_API_DOMAIN/v1/graphql ./generated --ts --td --header=x-hasura-admin-secret:$HASURA_GRAPHQL_ADMIN_SECRET && ./scripts/patch-zeus-codegen.sh'",
"check-translations": "node scripts/check-translations.js",
"test:unit": "vitest run",
"wrangler": "wrangler --config cloudflare-workers/backblaze-proxy/wrangler.toml"
},
"devDependencies": {
Expand All @@ -31,6 +32,7 @@
"shadcn-nuxt": "^2.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vitest": "3.2.4",
"vue": "^3.5.34",
"vue-router": "^4.5.1",
"workbox-core": "^7.3.0",
Expand Down
51 changes: 51 additions & 0 deletions utilities/chatSoundAutoMute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { shouldAutoMuteChatSound } from "./chatSoundAutoMute";

describe("shouldAutoMuteChatSound", () => {
const steamId = "76561197966948546";
const liveLineupMatch = {
id: "match-1",
status: "Live",
is_in_lineup: true,
};

it("auto-mutes when the current player is in game for a live lineup match", () => {
expect(
shouldAutoMuteChatSound(steamId, [liveLineupMatch], {
"match:match-1": new Map([[steamId, { inGame: true }]]),
}),
).toBe(true);
});

it("does not auto-mute when the current player is not reported in game", () => {
expect(
shouldAutoMuteChatSound(steamId, [liveLineupMatch], {
"match:match-1": new Map([[steamId, { inGame: false }]]),
}),
).toBe(false);
});

it("does not auto-mute outside live lineup matches", () => {
expect(
shouldAutoMuteChatSound(
steamId,
[
{ ...liveLineupMatch, status: "WaitingForServer" },
{ ...liveLineupMatch, id: "match-2", is_in_lineup: false },
],
{
"match:match-1": new Map([[steamId, { inGame: true }]]),
"match:match-2": new Map([[steamId, { inGame: true }]]),
},
),
).toBe(false);
});

it("does not auto-mute without a current Steam ID", () => {
expect(
shouldAutoMuteChatSound(undefined, [liveLineupMatch], {
"match:match-1": new Map([[steamId, { inGame: true }]]),
}),
).toBe(false);
});
});
37 changes: 37 additions & 0 deletions utilities/chatSoundAutoMute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
type MatchLike = {
id?: string;
status?: string;
is_in_lineup?: boolean;
};

type LobbyPresence = {
inGame?: boolean;
};

const LIVE_MATCH_STATUS = "Live";

export function shouldAutoMuteChatSound(
steamId: string | null | undefined,
myMatches: MatchLike[],
lobbyChat: Record<string, Map<string, LobbyPresence> | undefined>,
) {
if (!steamId) {
return false;
}

const normalizedSteamId = String(steamId);

return myMatches.some((match) => {
if (
!match?.id ||
!match.is_in_lineup ||
match.status !== LIVE_MATCH_STATUS
) {
return false;
}

return (
lobbyChat[`match:${match.id}`]?.get(normalizedSteamId)?.inGame === true
);
});
}
Loading