diff --git a/composables/useChatBlocks.ts b/composables/useChatBlocks.ts new file mode 100644 index 000000000..05e47e045 --- /dev/null +++ b/composables/useChatBlocks.ts @@ -0,0 +1,38 @@ +import { watch } from "vue"; +import { useChatTabs } from "~/composables/useChatTabs"; +import { directRoomId, directTabId } from "~/composables/useDirectMessages"; +import { usePlayerBlocks } from "~/composables/usePlayerBlocks"; +import { useAuthStore } from "~/stores/AuthStore"; +import socket from "~/web-sockets/Socket"; + +// The api sends no event on a block or an unblock, only changes what it sends +// from then on, so what this client already holds is its own to hide. A block +// made on another device arrives through the same subscription. +export function useChatBlocks() { + const { blocks, loaded } = usePlayerBlocks(); + const { closeTab } = useChatTabs(); + const authStore = useAuthStore(); + + watch( + () => (loaded.value ? blocks.value : null), + (rows) => { + if (!rows) { + return; + } + + const { added } = socket.setHiddenAuthors( + rows.map((row) => String(row.blocked_steam_id)), + ); + + const mySteamId = authStore.me?.steam_id; + if (!mySteamId) { + return; + } + + for (const steamId of added) { + closeTab(directTabId(directRoomId(mySteamId, steamId))); + } + }, + { immediate: true }, + ); +} diff --git a/composables/useChatTabSetup.ts b/composables/useChatTabSetup.ts index 2bb6e4bc6..3139f624d 100644 --- a/composables/useChatTabSetup.ts +++ b/composables/useChatTabSetup.ts @@ -99,7 +99,16 @@ export function useChatTabSetup() { // /chat/direct/conversations, which counts the whole thread rather than // the last 200 messages of it. Only the ids behind it are filled in, so a // deleted message can come off the badge. + // + // A room on screen counts nothing, as the live handler above doesn't. The + // cursor only moves when the tab is opened, so a rejoin (an unblock + // rejoins every room) would otherwise badge lines read while it was open. lobby.on("lobby:messages", (messages: any[]) => { + if (isChatTabOnScreen(tab.id)) { + setUnread(tab.id, 0); + return; + } + if (tab.type === "direct") { const count = unreadCounts.value[tab.id] ?? 0; diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index 75cfc2709..d36cd63b3 100644 --- a/composables/useIncomingDirectMessages.ts +++ b/composables/useIncomingDirectMessages.ts @@ -25,6 +25,10 @@ export function useIncomingDirectMessages() { unread = 0, position?: number, ) { + if (socket.hidesAuthor(peer?.steam_id)) { + return; + } + openTab({ id: directTabId(roomId), label: peer?.name ?? peer?.steam_id ?? roomId, @@ -114,6 +118,15 @@ export function useIncomingDirectMessages() { return; } + const peerId = + data.from?.steam_id ?? peerSteamId(data.roomId, steamId) ?? ""; + + // A blocked player's message can still be on its way when the block + // lands, and it must neither flash nor badge. + if (socket.hidesAuthor(peerId)) { + return; + } + // The room's own `lobby:chat` carries the same id when its tab is open. useTabFlash().signalChat("direct", data.message); @@ -121,8 +134,7 @@ export function useIncomingDirectMessages() { // 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) ?? "", + steam_id: peerId, name: data.from?.name, avatar_url: data.from?.avatar_url, }); diff --git a/layouts/default.vue b/layouts/default.vue index 3a816cdd8..fd9bb570d 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -8,6 +8,7 @@ import { e_player_roles_enum } from "~/generated/zeus"; import { useGtm } from "@/layouts/composables/useGtm"; import { useChatTabSetup } from "~/composables/useChatTabSetup"; import { useChatPresence } from "~/composables/useChatPresence"; +import { useChatBlocks } from "~/composables/useChatBlocks"; const AppSidebar = defineAsyncComponent( () => import("@/components/AppSidebar.vue"), @@ -53,6 +54,7 @@ useChatTabSetup(); useChatTabPersistence(); useIncomingDirectMessages(); useChatPresence(); +useChatBlocks(); const route = useRoute(); const authStore = useAuthStore(); diff --git a/pages/chat/[tabId].vue b/pages/chat/[tabId].vue index 9ef8658b8..65dea95df 100644 --- a/pages/chat/[tabId].vue +++ b/pages/chat/[tabId].vue @@ -8,6 +8,9 @@ import { setPageChatFocus, useChatPresence, } from "~/composables/useChatPresence"; +import { useChatBlocks } from "~/composables/useChatBlocks"; +import { peerSteamId } from "~/composables/useDirectMessages"; +import { usePlayerBlocks } from "~/composables/usePlayerBlocks"; import { chatThreadKey } from "~/utilities/chatThread"; import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; import { matchTeamLobbyId } from "~/utilities/matchTeamLobby"; @@ -80,7 +83,22 @@ const currentTab = computed(() => { return tabFromSession.value ?? tabFromQuery.value ?? tabFromId.value; }); -const hasTab = computed(() => currentTab.value !== null); +const { isBlocked } = usePlayerBlocks(); + +// The sidebar closes its tab for a blocked player, but this window has no tab +// to close. +const blockedPeer = computed(() => { + const tab = currentTab.value; + const mySteamId = useAuthStore().me?.steam_id; + + if (tab?.type !== "direct" || !mySteamId) { + return false; + } + + return isBlocked(peerSteamId(tab.lobbyId, String(mySteamId))); +}); + +const hasTab = computed(() => currentTab.value !== null && !blockedPeer.value); // Same second room the sidebar and the match page offer. This window can be // opened cold from a bare URL, so the match is looked up in the store rather @@ -131,6 +149,9 @@ const thread = computed(() => ); useChatPresence(); +// Nor does its block watcher, so this window would keep showing the lines of +// a player blocked while it was open. +useChatBlocks(); watch( thread, diff --git a/tests/components/ChatLobby.spec.ts b/tests/components/ChatLobby.spec.ts index 6caea846c..691976d8d 100644 --- a/tests/components/ChatLobby.spec.ts +++ b/tests/components/ChatLobby.spec.ts @@ -6,6 +6,14 @@ import ChatMessage from "~/components/chat/ChatMessage.vue"; import { useAuthStore } from "~/stores/AuthStore"; import socket, { type LobbyMessage } from "~/web-sockets/Socket"; +const { playNotificationSound } = vi.hoisted(() => ({ + playNotificationSound: vi.fn(), +})); + +vi.mock("~/composables/useSound", () => ({ + useSound: () => ({ playNotificationSound }), +})); + vi.mock("~/graphql/getGraphqlClient", () => ({ default: () => ({ query: vi.fn().mockResolvedValue({ data: {} }), @@ -310,3 +318,94 @@ describe("ChatLobby moderation", () => { expect(lobby.lastReadMessageCount).toBe(1); }); }); + +describe("ChatLobby blocked authors", () => { + const THIRD = "76561198000000003"; + + afterEach(() => { + socket.setHiddenAuthors([]); + playNotificationSound.mockClear(); + }); + + it("stays silent for a blocked player's live line", async () => { + signIn({ role: "user" }); + + const wrapper = await mountLobby({ + lobbyId: "blocked-live", + playNotificationSound: true, + }); + socket.setHiddenAuthors([OTHER]); + + socket.emit("lobby:match:blocked-live:chat", line("hidden", 0)); + await flushPromises(); + + expect(playNotificationSound).not.toHaveBeenCalled(); + expect(wrapper.emitted("message-received")).toBeUndefined(); + expect(wrapper.text()).not.toContain("line hidden"); + + socket.emit("lobby:match:blocked-live:chat", line("shown", 1, THIRD)); + await flushPromises(); + + expect(playNotificationSound).toHaveBeenCalledTimes(1); + expect(wrapper.emitted("message-received")).toHaveLength(1); + expect(wrapper.text()).toContain("line shown"); + }); + + it("keeps the New line on the same message when a blocked player's lines go", async () => { + signIn({ role: "user" }); + + const wrapper = await mountLobby({ + lobbyId: "blocked-divider", + isGlobalContext: true, + }); + socket.emit("lobby:match:blocked-divider:messages", { + messages: [ + line("a", 0), + line("b", 1, THIRD), + line("c", 2), + line("d", 3, THIRD), + line("e", 4), + ], + }); + await flushPromises(); + + const lobby = wrapper.vm as any; + lobby.lastReadMessageCount = 4; + + socket.setHiddenAuthors([OTHER]); + await flushPromises(); + + expect(lobby.messages.map((m: LobbyMessage) => m.id)).toEqual(["b", "d"]); + expect(lobby.lastReadMessageCount).toBe(2); + expect(wrapper.text()).not.toContain("line a"); + }); + + it("keeps the New line in place when both merged rooms lose their lines", async () => { + signIn({ role: "user" }); + + const wrapper = await mountLobby({ + lobbyId: "blocked-merged", + teamLobbyId: "blocked-merged:lineup-1", + }); + socket.emit("lobby:match:blocked-merged:messages", { + messages: [line("e1", 0), line("e2", 2, THIRD), line("e3", 4)], + }); + socket.emit("lobby:match_team:blocked-merged:lineup-1:messages", { + messages: [line("t1", 1, THIRD), line("t2", 3), line("t3", 5, THIRD)], + }); + await flushPromises(); + + const lobby = wrapper.vm as any; + lobby.lastReadMessageCount = 4; + + socket.setHiddenAuthors([OTHER]); + await flushPromises(); + + expect(lobby.messages.map((m: LobbyMessage) => m.id)).toEqual([ + "t1", + "e2", + "t3", + ]); + expect(lobby.lastReadMessageCount).toBe(2); + }); +}); diff --git a/tests/components/ChatPanel.spec.ts b/tests/components/ChatPanel.spec.ts index 221a580a3..d4fdd2c0c 100644 --- a/tests/components/ChatPanel.spec.ts +++ b/tests/components/ChatPanel.spec.ts @@ -36,6 +36,7 @@ vi.mock("~/web-sockets/Socket", async (importOriginal) => ({ }; }, joinLobby: () => ({ messages: [], on() {}, leave() {} }), + hidesAuthor: () => false, markLobbyRead() {}, }, })); diff --git a/tests/composables/useChatBlocks.spec.ts b/tests/composables/useChatBlocks.spec.ts new file mode 100644 index 000000000..380583f83 --- /dev/null +++ b/tests/composables/useChatBlocks.spec.ts @@ -0,0 +1,332 @@ +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 { useChatBlocks } from "~/composables/useChatBlocks"; +import { useChatTabs } from "~/composables/useChatTabs"; +import { useChatTabSetup } from "~/composables/useChatTabSetup"; +import { directRoomId, directTabId } from "~/composables/useDirectMessages"; +import { useIncomingDirectMessages } from "~/composables/useIncomingDirectMessages"; +import { setActiveHub } from "~/composables/useHubState"; +import { useRightSidebar } from "~/composables/useRightSidebar"; +import { useAuthStore } from "~/stores/AuthStore"; +import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; +import { chatThreadKey } from "~/utilities/chatThread"; +import socket, { type LobbyMessage } from "~/web-sockets/Socket"; + +const graphql = vi.hoisted(() => ({ + observers: [] as Array<{ observer: any; closed: boolean }>, +})); + +vi.mock("~/graphql/getGraphqlClient", () => ({ + default: () => ({ + mutate: vi.fn(), + subscribe: () => ({ + subscribe(observer: any) { + const entry = { observer, closed: false }; + graphql.observers.push(entry); + return { + unsubscribe() { + entry.closed = true; + }, + }; + }, + }), + }), +})); + +const ME = "76561198000000001"; +const DANA = "76561198000000002"; +const EVAN = "76561198000000003"; + +const at = (minute: number) => + new Date(Date.UTC(2026, 8, 28, 12, minute)).toISOString(); + +const line = (id: string, minute: number, from: string): LobbyMessage => ({ + id, + message: `line ${id}`, + timestamp: at(minute), + from: { steam_id: from }, +}); + +const ids = (messages: readonly LobbyMessage[]) => + messages.map((message) => message.id); + +function setBlocks(...steamIds: string[]) { + const open = graphql.observers.filter((entry) => !entry.closed); + expect(open).toHaveLength(1); + open[0].observer.next({ + data: { + player_blocks: steamIds.map((steamId) => ({ + blocked_steam_id: steamId, + created_at: at(0), + blocked: { steam_id: steamId, name: steamId, avatar_url: null }, + })), + }, + }); +} + +function connect() { + const send = vi.fn(); + (socket as any).connected = true; + (socket as any).connection = { send }; + return send; +} + +function disconnect() { + (socket as any).connected = false; + (socket as any).connection = undefined; +} + +function sentJoins(send: ReturnType) { + return send.mock.calls + .map(([payload]) => JSON.parse(payload)) + .filter(({ event }) => event === "lobby:join") + .map(({ data }) => `${data.type}:${data.id}`); +} + +let counter = 0; +let lobbyId = ""; +let groupTab = ""; +let groupRoom = ""; +let dmRoomId = ""; +let dmTab = ""; +let unmount: (() => void) | undefined; + +const unread = (tabId: string) => useChatTabs().unreadCounts.value[tabId]; +const hasTab = (tabId: string) => + useChatTabs().tabs.value.some((tab) => tab.id === tabId); +const groupMessages = () => socket.lobbyMessages("matchmaking", lobbyId); + +async function mount(setup: () => void) { + const wrapper = await mountSuspended( + defineComponent({ + setup() { + setup(); + return () => h("div"); + }, + }), + ); + unmount = () => wrapper.unmount(); + await flushPromises(); +} + +beforeEach(async () => { + lobbyId = `chat-blocks-${++counter}`; + groupTab = `matchmaking:${lobbyId}`; + groupRoom = `lobby:matchmaking:${lobbyId}`; + dmRoomId = directRoomId(ME, DANA); + dmTab = directTabId(dmRoomId); + + vi.spyOn(console, "info").mockImplementation(() => {}); + useChatTabs().clearAll(); + useMatchLobbyStore().chatTournaments = []; + useMatchLobbyStore().myMatches = []; + useRightSidebar().setRightSidebarOpen(false); + vi.stubGlobal( + "$fetch", + vi.fn().mockResolvedValue({ + threads: [ + { thread: chatThreadKey("matchmaking", lobbyId), lastReadAt: at(1) }, + ], + conversations: [], + }), + ); + useAuthStore().me = { + steam_id: ME, + current_lobby_id: lobbyId, + role: "user", + } as any; + + await mount(() => { + useChatTabSetup(); + useIncomingDirectMessages(); + useChatBlocks(); + }); + + setBlocks(); + + useChatTabs().openTab({ + id: dmTab, + label: "Dana", + instance: "direct", + type: "direct", + lobbyId: dmRoomId, + steamId: DANA, + activate: false, + }); + useChatTabs().setUnread(dmTab, 2); + await flushPromises(); + + socket.emit(`${groupRoom}:messages`, { + messages: [ + line("read", 0, EVAN), + line("dana-1", 2, DANA), + line("dana-2", 3, DANA), + line("evan", 4, EVAN), + ], + }); + socket.emit(`lobby:direct:${dmRoomId}:messages`, { + messages: [ + line("dm-1", 2, DANA), + line("dm-2", 3, DANA), + line("dm-mine", 4, ME), + ], + }); +}); + +afterEach(async () => { + unmount?.(); + unmount = undefined; + useAuthStore().me = undefined; + await flushPromises(); + socket.setHiddenAuthors([]); + disconnect(); + useChatTabs().clearAll(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("useChatBlocks", () => { + it("hides a newly blocked player's lines, uncounts them and closes the conversation", async () => { + expect(unread(groupTab)).toBe(3); + expect(unread(dmTab)).toBe(2); + + setBlocks(DANA); + await flushPromises(); + + expect(ids(groupMessages())).toEqual(["read", "evan"]); + expect(unread(groupTab)).toBe(1); + expect(hasTab(dmTab)).toBe(false); + expect(unread(dmTab)).toBeUndefined(); + }); + + it("drops a blocked player's live line without counting it", async () => { + setBlocks(DANA); + await flushPromises(); + + socket.emit(`${groupRoom}:chat`, line("dana-3", 5, DANA)); + socket.emit(`${groupRoom}:chat`, line("evan-2", 6, EVAN)); + + expect(ids(groupMessages())).toEqual(["read", "evan", "evan-2"]); + expect(unread(groupTab)).toBe(2); + }); + + it("ignores a reaction to a line it hid, and hides their reactions", async () => { + setBlocks(DANA); + await flushPromises(); + + socket.emit(`${groupRoom}:reaction`, { + id: "dana-1", + reactions: { heart: [EVAN] }, + }); + socket.emit(`${groupRoom}:reaction`, { + id: "evan", + reactions: { heart: [DANA], fire: [DANA, ME] }, + }); + + expect(ids(groupMessages())).toEqual(["read", "evan"]); + expect(groupMessages().map((message) => message.reactions)).toEqual([ + undefined, + { fire: [ME] }, + ]); + }); + + it("never reopens the conversation from a message already on its way", async () => { + setBlocks(DANA); + await flushPromises(); + + socket.emit("direct:incoming", { + roomId: dmRoomId, + from: { steam_id: DANA, name: "Dana" }, + }); + + expect(hasTab(dmTab)).toBe(false); + }); + + it("asks the open rooms for their history again on an unblock", async () => { + setBlocks(DANA); + await flushPromises(); + const send = connect(); + + setBlocks(); + await flushPromises(); + + expect(sentJoins(send)).toContain(`matchmaking:${lobbyId}`); + expect(hasTab(dmTab)).toBe(false); + + socket.emit(`${groupRoom}:messages`, { + messages: [ + line("read", 0, EVAN), + line("dana-1", 2, DANA), + line("dana-2", 3, DANA), + line("evan", 4, EVAN), + ], + }); + expect(ids(groupMessages())).toEqual(["read", "dana-1", "dana-2", "evan"]); + }); + + it("hides a second player blocked on top of the first", async () => { + setBlocks(EVAN); + await flushPromises(); + + setBlocks(EVAN, DANA); + await flushPromises(); + + expect(ids(groupMessages())).toEqual([]); + expect(unread(groupTab)).toBe(0); + expect(hasTab(dmTab)).toBe(false); + }); + + it("leaves the badge of the room on screen clear when an unblock brings lines back", async () => { + useChatTabs().setActiveTab(groupTab); + useRightSidebar().setRightSidebarOpen(true); + setActiveHub("chat"); + await flushPromises(); + useChatTabs().resetUnread(groupTab); + + setBlocks(DANA); + await flushPromises(); + setBlocks(); + await flushPromises(); + + socket.emit(`${groupRoom}:messages`, { + messages: [ + line("read", 0, EVAN), + line("dana-1", 2, DANA), + line("dana-2", 3, DANA), + line("evan", 4, EVAN), + ], + }); + + expect(unread(groupTab)).toBe(0); + }); + + it("still hears an unblock made while it was unmounted", async () => { + setBlocks(DANA); + await flushPromises(); + unmount?.(); + + setBlocks(); + await flushPromises(); + expect(socket.hidesAuthor(DANA)).toBe(true); + + await mount(() => { + useChatBlocks(); + }); + + expect(socket.hidesAuthor(DANA)).toBe(false); + }); + + it("does not read signing out as unblocking everyone", async () => { + setBlocks(DANA); + await flushPromises(); + const send = connect(); + + useAuthStore().me = undefined; + await flushPromises(); + + expect(sentJoins(send)).toEqual([]); + expect(socket.hidesAuthor(DANA)).toBe(true); + }); +}); diff --git a/tests/composables/useIncomingDirectMessages.spec.ts b/tests/composables/useIncomingDirectMessages.spec.ts index bedc96137..8e6760b62 100644 --- a/tests/composables/useIncomingDirectMessages.spec.ts +++ b/tests/composables/useIncomingDirectMessages.spec.ts @@ -16,6 +16,7 @@ const socketMock = vi.hoisted(() => { return { handlers, lobbies, + hidden: new Set(), emit(event: string, data: unknown) { for (const handler of handlers.get(event) ?? []) { handler(data); @@ -65,6 +66,9 @@ vi.mock("~/web-sockets/Socket", async (importOriginal) => ({ lobbyMessages() { return []; }, + hidesAuthor(steamId?: string | null) { + return socketMock.hidden.has(String(steamId)); + }, markLobbyRead() {}, }, })); @@ -134,6 +138,7 @@ const unread = () => useChatTabs().unreadCounts.value[TAB] ?? 0; beforeEach(() => { socketMock.handlers.clear(); socketMock.lobbies.clear(); + socketMock.hidden.clear(); useChatTabs().clearAll(); useAuthStore().me = { steam_id: ME } as any; stubApi(); @@ -160,6 +165,17 @@ describe("useIncomingDirectMessages", () => { expect(activeTabId.value).toBeNull(); }); + it("ignores a message from a blocked player that was already on its way", async () => { + await mountChat(); + socketMock.hidden.add(FRIEND); + + incoming("m1"); + + expect(useChatTabs().tabs.value.map((tab) => tab.id)).not.toContain(TAB); + expect(unread()).toBe(0); + expect(useChatTabs().totalUnread.value).toBe(0); + }); + it("counts every message in a burst that lands before the room is joined", async () => { await mountChat(); diff --git a/tests/pages/chat/popout.spec.ts b/tests/pages/chat/popout.spec.ts new file mode 100644 index 000000000..d62838588 --- /dev/null +++ b/tests/pages/chat/popout.spec.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import ChatPopout from "~/pages/chat/[tabId].vue"; +import ChatLobby from "~/components/chat/ChatLobby.vue"; +import { directRoomId } from "~/composables/useDirectMessages"; +import { useAuthStore } from "~/stores/AuthStore"; +import socket, { type LobbyMessage } from "~/web-sockets/Socket"; + +const graphql = vi.hoisted(() => ({ + observers: [] as Array<{ observer: any; closed: boolean }>, +})); + +vi.mock("~/graphql/getGraphqlClient", () => ({ + default: () => ({ + query: vi.fn().mockResolvedValue({ data: {} }), + mutate: vi.fn(), + subscribe: () => ({ + subscribe(observer: any) { + const entry = { observer, closed: false }; + graphql.observers.push(entry); + return { + unsubscribe() { + entry.closed = true; + }, + }; + }, + }), + }), +})); + +const ME = "76561198000000001"; +const DANA = "76561198000000002"; +const EVAN = "76561198000000003"; + +const line = (id: string, minute: number, from: string): LobbyMessage => ({ + id, + message: `line ${id}`, + timestamp: new Date(Date.UTC(2026, 8, 28, 12, minute)).toISOString(), + from: { steam_id: from, name: `Player ${from}` }, +}); + +function setBlocks(...steamIds: string[]) { + const open = graphql.observers.filter((entry) => !entry.closed); + expect(open).toHaveLength(1); + open[0].observer.next({ + data: { + player_blocks: steamIds.map((steamId) => ({ + blocked_steam_id: steamId, + created_at: "2026-09-28T12:00:00Z", + blocked: { steam_id: steamId, name: steamId, avatar_url: null }, + })), + }, + }); +} + +let unmount: (() => void) | undefined; + +async function mountPopout(tabId: string) { + const wrapper = await mountSuspended(ChatPopout, { + route: `/chat/${encodeURIComponent(tabId)}`, + }); + unmount = () => wrapper.unmount(); + await flushPromises(); + return wrapper; +} + +beforeEach(async () => { + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.stubGlobal("$fetch", vi.fn().mockResolvedValue({ threads: [] })); + useAuthStore().me = { steam_id: ME, role: "user" } as any; + await flushPromises(); +}); + +afterEach(async () => { + unmount?.(); + unmount = undefined; + useAuthStore().me = undefined; + await flushPromises(); + socket.setHiddenAuthors([]); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("chat pop-out", () => { + it("hides a player blocked while the window is open", async () => { + const wrapper = await mountPopout("match:popout-match"); + setBlocks(); + socket.emit("lobby:match:popout-match:messages", { + messages: [line("a", 0, DANA), line("b", 1, EVAN)], + }); + await flushPromises(); + + expect(wrapper.text()).toContain("line a"); + + setBlocks(DANA); + await flushPromises(); + + expect(wrapper.text()).not.toContain("line a"); + expect(wrapper.text()).toContain("line b"); + }); + + it("stops showing a conversation with a player who gets blocked", async () => { + const wrapper = await mountPopout(`direct:${directRoomId(ME, DANA)}`); + setBlocks(); + await flushPromises(); + + expect(wrapper.findComponent(ChatLobby).exists()).toBe(true); + + setBlocks(DANA); + await flushPromises(); + + expect(wrapper.findComponent(ChatLobby).exists()).toBe(false); + expect(wrapper.text()).toContain( + "This chat is not currently open in your session.", + ); + }); + + it("keeps a conversation with anyone else", async () => { + const wrapper = await mountPopout(`direct:${directRoomId(ME, EVAN)}`); + setBlocks(DANA); + await flushPromises(); + + expect(wrapper.findComponent(ChatLobby).exists()).toBe(true); + }); +}); diff --git a/tests/utilities/chatLobbyMessages.spec.ts b/tests/utilities/chatLobbyMessages.spec.ts index 27fc3d4d2..5e74f03e4 100644 --- a/tests/utilities/chatLobbyMessages.spec.ts +++ b/tests/utilities/chatLobbyMessages.spec.ts @@ -3,11 +3,14 @@ import { applyChatMessageEdit, applyChatMessageReactions, chatMessageKey, + hideChatAuthors, insertChatMessage, isChatMessageDeleted, + isChatMessageFrom, mergeChatSnapshot, newestMessageIdsFrom, removeChatMessage, + withoutChatReactors, } from "~/utilities/chatLobbyMessages"; import type { LobbyMessage } from "~/web-sockets/Socket"; @@ -42,6 +45,119 @@ describe("removeChatMessage", () => { }); }); +describe("hideChatAuthors", () => { + const DANA = "76561198000000002"; + const EVAN = "76561198000000003"; + + const by = (id: string, minute: number, steamId: string): LobbyMessage => ({ + ...line(id, minute), + from: { steam_id: steamId }, + }); + + it("removes every line by the authors and says where each one was", () => { + const messages = [ + by("a", 0, DANA), + line("b", 1), + by("c", 2, DANA), + by("d", 3, EVAN), + line("e", 4), + ]; + + const removed = hideChatAuthors(messages, new Set([DANA, EVAN])); + + expect(ids(removed!.messages)).toEqual(["b", "e"]); + expect( + removed!.removed.map(({ message, index }) => [message.id, index]), + ).toEqual([ + ["a", 0], + ["c", 1], + ["d", 1], + ]); + expect(ids(messages)).toEqual(["a", "b", "c", "d", "e"]); + }); + + it("gives the same indexes as deleting the lines one at a time", () => { + const messages = [line("a", 0), by("b", 1, DANA), by("c", 2, DANA)]; + const removed = hideChatAuthors(messages, new Set([DANA]))!; + + let current = messages; + for (const { message, index } of removed.removed) { + const single = removeChatMessage(current, message.id as string)!; + expect(single.index).toBe(index); + current = single.messages; + } + + expect(current).toEqual(removed.messages); + }); + + it("returns null when none of the lines are theirs", () => { + expect(hideChatAuthors([line("a", 0)], new Set([DANA]))).toBeNull(); + expect(hideChatAuthors([by("a", 0, DANA)], new Set())).toBeNull(); + }); + + it("keeps a line that has no author", () => { + const system: LobbyMessage = { id: "s", message: "x", timestamp: "" }; + + const removed = hideChatAuthors( + [system, by("a", 0, DANA)], + new Set([DANA]), + ); + + expect(ids(removed!.messages)).toEqual(["s"]); + }); + + it("takes their reactions off everyone else's lines", () => { + const untouched = { ...line("a", 0), reactions: { heart: [EVAN] } }; + const messages = [ + untouched, + { ...line("b", 1), reactions: { heart: [DANA, EVAN], fire: [DANA] } }, + ]; + + const hidden = hideChatAuthors(messages, new Set([DANA])); + + expect(hidden!.removed).toEqual([]); + expect(hidden!.messages[0]).toBe(untouched); + expect(hidden!.messages[1].reactions).toEqual({ heart: [EVAN] }); + expect(messages[1].reactions).toEqual({ + heart: [DANA, EVAN], + fire: [DANA], + }); + }); +}); + +describe("withoutChatReactors", () => { + it("drops the authors and any reaction left with nobody", () => { + expect( + withoutChatReactors( + { heart: ["1", "2"], fire: ["2"], sad: ["3"] }, + new Set(["2"]), + ), + ).toEqual({ heart: ["1"], sad: ["3"] }); + }); + + it("hands back the same object when nobody is dropped", () => { + const reactions = { heart: ["1"] }; + + expect(withoutChatReactors(reactions, new Set(["2"]))).toBe(reactions); + expect(withoutChatReactors(undefined, new Set(["2"]))).toBeUndefined(); + }); +}); + +describe("isChatMessageFrom", () => { + it("compares steam ids as strings", () => { + const message = { + ...line("a", 0), + from: { steam_id: 42 as unknown as string }, + }; + + expect(isChatMessageFrom(message, new Set(["42"]))).toBe(true); + expect(isChatMessageFrom(message, new Set(["43"]))).toBe(false); + expect( + isChatMessageFrom({ ...message, from: undefined }, new Set(["42"])), + ).toBe(false); + }); +}); + describe("insertChatMessage", () => { it("places a late line by its time", () => { const messages = insertChatMessage( diff --git a/tests/utilities/playerBlocks.spec.ts b/tests/utilities/playerBlocks.spec.ts index 4e32e9568..f04c379df 100644 --- a/tests/utilities/playerBlocks.spec.ts +++ b/tests/utilities/playerBlocks.spec.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { PLAYER_BLOCKED_ERROR, + blockedIdsChange, playerBlockErrorKey, } from "~/utilities/playerBlocks"; @@ -63,6 +64,21 @@ describe("playerBlockErrorKey", () => { }); }); +describe("blockedIdsChange", () => { + it("names the ids that joined and left the set", () => { + expect( + blockedIdsChange(new Set(["a", "b"]), new Set(["b", "c", "d"])), + ).toEqual({ added: ["c", "d"], removed: ["a"] }); + }); + + it("reports nothing for the same set", () => { + expect(blockedIdsChange(new Set(["a"]), new Set(["a"]))).toEqual({ + added: [], + removed: [], + }); + }); +}); + describe("global apollo error toast", () => { async function reportedKeys(message: string) { const app = useNuxtApp() as any; diff --git a/tests/web-sockets/SocketChatError.spec.ts b/tests/web-sockets/SocketChatError.spec.ts index f74568f2f..497545d44 100644 --- a/tests/web-sockets/SocketChatError.spec.ts +++ b/tests/web-sockets/SocketChatError.spec.ts @@ -911,3 +911,204 @@ describe("Socket lobby reactions", () => { expect(ids(lobby.messages)).toEqual(["a", "b"]); }); }); + +describe("Socket live order", () => { + let lobby: ReturnType; + + beforeEach(() => { + vi.spyOn(console, "info").mockImplementation(() => {}); + lobby = socket.joinLobby("socket-spec", "match", "live-order"); + socket.emit("lobby:match:live-order:messages", { + messages: [line("a", 0), line("b", 1)], + }); + }); + + afterEach(() => { + lobby.leave(); + vi.restoreAllMocks(); + }); + + it("orders a late live line by its timestamp and keeps one copy", () => { + socket.emit("lobby:match:live-order:chat", line("d", 3)); + socket.emit("lobby:match:live-order:chat", line("c", 2)); + socket.emit("lobby:match:live-order:chat", line("d", 3)); + + expect(ids(lobby.messages)).toEqual(["a", "b", "c", "d"]); + }); +}); + +describe("Socket hidden authors", () => { + const DANA = "76561198000000003"; + const ELI = "76561198000000004"; + const FAY = "76561198000000005"; + + const by = (id: string, minute: number, steamId: string): LobbyMessage => ({ + ...line(id, minute), + from: { steam_id: steamId }, + }); + + const matchHistory = () => [ + by("m1", 0, DANA), + { ...line("m2", 1), reactions: { heart: [DANA, FAY], fire: [DANA] } }, + by("m3", 2, DANA), + ]; + + let match: ReturnType; + let lobby: ReturnType; + + beforeEach(() => { + vi.spyOn(console, "info").mockImplementation(() => {}); + match = socket.joinLobby("socket-spec", "match", "hidden-match"); + lobby = socket.joinLobby("socket-spec", "matchmaking", "hidden-lobby"); + socket.emit("lobby:match:hidden-match:messages", { + messages: matchHistory(), + }); + socket.emit("lobby:matchmaking:hidden-lobby:messages", { + messages: [line("l1", 0), by("l2", 1, DANA), by("l3", 2, ELI)], + }); + }); + + afterEach(() => { + socket.setHiddenAuthors([]); + match.leave(); + lobby.leave(); + disconnect(); + vi.restoreAllMocks(); + }); + + function sentJoins(send: ReturnType) { + return send.mock.calls + .map(([payload]) => JSON.parse(payload)) + .filter(({ event }) => event === "lobby:join") + .map(({ data }) => `${data.type}:${data.id}`); + } + + it("takes their lines out of every open room", () => { + const deleted = vi.fn(); + match.on("lobby:deleted", deleted); + + socket.setHiddenAuthors([DANA]); + + expect(ids(match.messages)).toEqual(["m2"]); + expect(ids(lobby.messages)).toEqual(["l1", "l3"]); + expect(deleted.mock.calls.map(([event]) => event)).toEqual([ + { message: by("m1", 0, DANA), index: 0 }, + { message: by("m3", 2, DANA), index: 1 }, + ]); + }); + + it("names only the players the set gained or lost", () => { + expect(socket.setHiddenAuthors([DANA, ELI])).toEqual({ + added: [DANA, ELI], + removed: [], + }); + expect(socket.setHiddenAuthors([ELI])).toEqual({ + added: [], + removed: [DANA], + }); + expect(socket.hidesAuthor(DANA)).toBe(false); + expect(socket.hidesAuthor(ELI)).toBe(true); + }); + + it("drops their live lines without telling the room", () => { + const chat = vi.fn(); + match.on("lobby:chat", chat); + socket.setHiddenAuthors([DANA]); + + socket.emit("lobby:match:hidden-match:chat", by("m4", 3, DANA)); + socket.emit("lobby:match:hidden-match:chat", line("m5", 4)); + + expect(ids(match.messages)).toEqual(["m2", "m5"]); + expect(chat).toHaveBeenCalledTimes(1); + expect(chat).toHaveBeenCalledWith(line("m5", 4)); + }); + + it("keeps their lines and reactions out of a snapshot built before the block", () => { + socket.setHiddenAuthors([DANA]); + + socket.emit("lobby:match:hidden-match:messages", { + messages: matchHistory(), + }); + + expect(ids(match.messages)).toEqual(["m2"]); + expect(match.messages[0].reactions).toEqual({ heart: [FAY] }); + }); + + it("takes their reactions off the lines still shown", () => { + socket.setHiddenAuthors([DANA]); + + expect(match.messages[0].reactions).toEqual({ heart: [FAY] }); + + socket.emit("lobby:match:hidden-match:reaction", { + id: "m2", + reactions: { heart: [DANA, FAY], sad: [DANA] }, + }); + expect(match.messages[0].reactions).toEqual({ heart: [FAY] }); + + socket.emit("lobby:match:hidden-match:messages", { + messages: matchHistory(), + }); + expect(match.messages[0].reactions).toEqual({ heart: [FAY] }); + }); + + it("never brings a hidden line back through an edit or a reaction", () => { + socket.setHiddenAuthors([DANA]); + + socket.emit("lobby:match:hidden-match:edited", { + id: "m1", + message: "edited", + edited_at: new Date(Date.UTC(2026, 8, 28, 13)).toISOString(), + }); + socket.emit("lobby:match:hidden-match:reaction", { + id: "m3", + reactions: { heart: [ELI] }, + }); + + expect(ids(match.messages)).toEqual(["m2"]); + }); + + it("asks every open room for its history again on an unblock", () => { + socket.setHiddenAuthors([DANA]); + const send = connect(); + + socket.setHiddenAuthors([]); + + expect(sentJoins(send)).toEqual( + expect.arrayContaining([ + "match:hidden-match", + "matchmaking:hidden-lobby", + ]), + ); + + socket.emit("lobby:match:hidden-match:messages", { + messages: matchHistory(), + }); + expect(ids(match.messages)).toEqual(["m1", "m2", "m3"]); + expect(match.messages[1].reactions).toEqual({ + heart: [DANA, FAY], + fire: [DANA], + }); + }); + + it("keeps a line deleted while hidden out of the history that comes back", () => { + socket.setHiddenAuthors([DANA]); + socket.emit("lobby:match:hidden-match:deleted", { id: "m3" }); + socket.setHiddenAuthors([]); + + socket.emit("lobby:match:hidden-match:messages", { + messages: matchHistory(), + }); + + expect(ids(match.messages)).toEqual(["m1", "m2"]); + }); + + it("rejoins nothing when nobody left the set", () => { + socket.setHiddenAuthors([DANA]); + const send = connect(); + + socket.setHiddenAuthors([DANA, ELI]); + socket.setHiddenAuthors([DANA, ELI]); + + expect(sentJoins(send)).toEqual([]); + }); +}); diff --git a/utilities/chatLobbyMessages.ts b/utilities/chatLobbyMessages.ts index 72c473e56..65d7e28bf 100644 --- a/utilities/chatLobbyMessages.ts +++ b/utilities/chatLobbyMessages.ts @@ -179,6 +179,91 @@ export function removeChatMessage( return { messages, message, index }; } +export function isChatMessageFrom( + message: LobbyMessage, + authors: ReadonlySet, +) { + const steamId = message?.from?.steam_id; + + return steamId != null && authors.has(String(steamId)); +} + +export interface HiddenChatAuthors { + messages: LobbyMessage[]; + removed: Array>; +} + +// Their lines go, and so do their reactions on everyone else's. Each index is +// where a line sat once the ones before it were gone, as if they had been +// deleted one at a time. +export function hideChatAuthors( + current: LobbyMessage[], + authors: ReadonlySet, +): HiddenChatAuthors | null { + if (authors.size === 0) { + return null; + } + + const messages: LobbyMessage[] = []; + const removed: HiddenChatAuthors["removed"] = []; + let changed = false; + + for (const message of current) { + if (isChatMessageFrom(message, authors)) { + removed.push({ message, index: messages.length }); + continue; + } + + const reactions = withoutChatReactors(message?.reactions, authors); + if (reactions === message?.reactions) { + messages.push(message); + continue; + } + + changed = true; + messages.push({ ...message, reactions }); + } + + if (removed.length === 0 && !changed) { + return null; + } + + return { messages, removed }; +} + +export function withoutChatReactors( + reactions: ChatReactions | undefined, + authors: ReadonlySet, +): ChatReactions | undefined { + if (!reactions || typeof reactions !== "object" || authors.size === 0) { + return reactions; + } + + const kept: ChatReactions = {}; + let changed = false; + + for (const [reaction, steamIds] of Object.entries(reactions)) { + if (!Array.isArray(steamIds)) { + kept[reaction] = steamIds; + continue; + } + + const remaining = steamIds.filter( + (steamId) => !authors.has(String(steamId)), + ); + + if (remaining.length !== steamIds.length) { + changed = true; + } + + if (remaining.length > 0) { + kept[reaction] = remaining; + } + } + + return changed ? kept : reactions; +} + // A conversation's unread count comes from the server, which counts every // message from the other party after the read cursor -- which are always the // newest of theirs. So the ids behind the count can be read off the room diff --git a/utilities/playerBlocks.ts b/utilities/playerBlocks.ts index e680cda58..000e29c8a 100644 --- a/utilities/playerBlocks.ts +++ b/utilities/playerBlocks.ts @@ -7,3 +7,13 @@ export function playerBlockErrorKey(message?: string | null): string | null { ? "player_blocks.errors.player_blocked" : null; } + +export function blockedIdsChange( + previous: ReadonlySet, + next: ReadonlySet, +) { + return { + added: [...next].filter((steamId) => !previous.has(steamId)), + removed: [...previous].filter((steamId) => !next.has(steamId)), + }; +} diff --git a/web-sockets/Socket.ts b/web-sockets/Socket.ts index 5aa34ebc3..96448b4f5 100644 --- a/web-sockets/Socket.ts +++ b/web-sockets/Socket.ts @@ -12,15 +12,19 @@ import { applyChatMessageEdit, applyChatMessageReactions, chatMessageKey, + hideChatAuthors, insertChatMessage, isChatMessageDeleted, + isChatMessageFrom, mergeChatSnapshot, removeChatMessage, + withoutChatReactors, type ChatMessageEdit, type ChatMessageReactionsUpdate, type RemovedChatMessage, } from "~/utilities/chatLobbyMessages"; import type { ChatReaction } from "~/constants/chat"; +import { blockedIdsChange } from "~/utilities/playerBlocks"; import guid from "~/utilities/uuid"; export { chatMessageKey, chatMessageTime } from "~/utilities/chatLobbyMessages"; @@ -135,6 +139,10 @@ export class Socket extends EventEmitter { private instanceCounter = 0; private pendingRequests: Map = new Map(); private reactionsInFlight = new Set(); + // The api stops sending a blocked player's lines once the block has + // committed, but not one already on its way, and it still sends their + // reactions. + private hiddenAuthors: ReadonlySet = new Set(); // The api answers a request it failed to carry out with nothing at all. private static readonly REQUEST_TIMEOUT_MS = 8000; // How long a request that timed out still recognises its answer, so a slow @@ -414,6 +422,33 @@ export class Socket extends EventEmitter { } } + public hidesAuthor(steamId?: string | number | null) { + return steamId != null && this.hiddenAuthors.has(String(steamId)); + } + + // Diffed here rather than by the caller, which can unmount and miss an + // unblock that this set would then never hear about. + public setHiddenAuthors(steamIds: Array) { + const next = new Set(steamIds.map(String)); + const change = blockedIdsChange(this.hiddenAuthors, next); + this.hiddenAuthors = next; + + if (change.added.length > 0) { + const added = new Set(change.added); + for (const lobby of this.lobbies.values()) { + this.hideLobbyAuthors(lobby, added); + } + } + + // The api sends nothing on an unblock, so the rooms' history is asked for + // again to bring back whatever of theirs is still there. + if (change.removed.length > 0) { + this.rejoinAll(); + } + + return change; + } + public event(event: string, data: Record) { if (!this.connected || !this.connection) { this.offlineQueue.push({ event, data }); @@ -783,9 +818,10 @@ export class Socket extends EventEmitter { } private mergeLobbyMessages(lobby: LobbyState, messages: LobbyMessage[]) { + const snapshot = messages || []; const merged = mergeChatSnapshot( lobby.messages.value, - messages, + hideChatAuthors(snapshot, this.hiddenAuthors)?.messages ?? snapshot, lobby.deleted, lobby.reacted, ); @@ -801,7 +837,11 @@ export class Socket extends EventEmitter { private addLobbyMessage(lobby: LobbyState, message: LobbyMessage) { const key = chatMessageKey(message); - if (lobby.seen.has(key) || isChatMessageDeleted(message, lobby.deleted)) { + if ( + lobby.seen.has(key) || + isChatMessageDeleted(message, lobby.deleted) || + isChatMessageFrom(message, this.hiddenAuthors) + ) { return; } lobby.seen.add(key); @@ -835,7 +875,10 @@ export class Socket extends EventEmitter { ) { const messages = applyChatMessageReactions( lobby.messages.value, - update, + { + ...update, + reactions: withoutChatReactors(update?.reactions, this.hiddenAuthors), + }, lobby.deleted, ); @@ -874,6 +917,20 @@ export class Socket extends EventEmitter { this.emitToLobbyInstances(lobby, "lobby:deleted", event); } + // No tombstones: an unblock brings these back with the room's history. + private hideLobbyAuthors(lobby: LobbyState, authors: ReadonlySet) { + const hidden = hideChatAuthors(lobby.messages.value, authors); + if (!hidden) { + return; + } + + lobby.messages.value = hidden.messages; + + for (const event of hidden.removed) { + this.emitToLobbyInstances(lobby, "lobby:deleted", event); + } + } + private emitToLobbyInstances( lobby: LobbyState, event: string,