From 9b173f0db4aa03cb59aa37b33b61e5eea6d8078f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 19:38:21 -0400 Subject: [PATCH 1/3] feature: hide blocked players in chat --- composables/useChatBlocks.ts | 47 ++++ composables/useIncomingDirectMessages.ts | 4 + layouts/default.vue | 2 + pages/chat/[tabId].vue | 4 + tests/components/ChatLobby.spec.ts | 70 ++++++ tests/composables/useChatBlocks.spec.ts | 278 ++++++++++++++++++++++ tests/utilities/chatLobbyMessages.spec.ts | 79 ++++++ tests/utilities/playerBlocks.spec.ts | 16 ++ tests/web-sockets/SocketChatError.spec.ts | 134 +++++++++++ utilities/chatLobbyMessages.ts | 43 ++++ utilities/playerBlocks.ts | 10 + web-sockets/Socket.ts | 59 ++++- 12 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 composables/useChatBlocks.ts create mode 100644 tests/composables/useChatBlocks.spec.ts diff --git a/composables/useChatBlocks.ts b/composables/useChatBlocks.ts new file mode 100644 index 000000000..b5b6cc5e8 --- /dev/null +++ b/composables/useChatBlocks.ts @@ -0,0 +1,47 @@ +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 { blockedIdsChange } from "~/utilities/playerBlocks"; +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(); + + let applied = new Set(); + + watch( + () => (loaded.value ? blocks.value : null), + (rows) => { + if (!rows) { + return; + } + + const next = new Set(rows.map((row) => String(row.blocked_steam_id))); + const { added, removed } = blockedIdsChange(applied, next); + applied = next; + + if (added.length > 0) { + socket.hideAuthors(added); + + const mySteamId = authStore.me?.steam_id; + if (mySteamId) { + for (const steamId of added) { + closeTab(directTabId(directRoomId(mySteamId, steamId))); + } + } + } + + if (removed.length > 0) { + socket.showAuthors(removed); + } + }, + { immediate: true }, + ); +} diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index 75cfc2709..fae6f4ff6 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, 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..d938523ef 100644 --- a/pages/chat/[tabId].vue +++ b/pages/chat/[tabId].vue @@ -8,6 +8,7 @@ import { setPageChatFocus, useChatPresence, } from "~/composables/useChatPresence"; +import { useChatBlocks } from "~/composables/useChatBlocks"; import { chatThreadKey } from "~/utilities/chatThread"; import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; import { matchTeamLobbyId } from "~/utilities/matchTeamLobby"; @@ -131,6 +132,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..4944cd4fd 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,65 @@ describe("ChatLobby moderation", () => { expect(lobby.lastReadMessageCount).toBe(1); }); }); + +describe("ChatLobby blocked authors", () => { + const THIRD = "76561198000000003"; + + afterEach(() => { + socket.showAuthors([OTHER]); + 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.hideAuthors([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.hideAuthors([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"); + }); +}); diff --git a/tests/composables/useChatBlocks.spec.ts b/tests/composables/useChatBlocks.spec.ts new file mode 100644 index 000000000..1490f904d --- /dev/null +++ b/tests/composables/useChatBlocks.spec.ts @@ -0,0 +1,278 @@ +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 { 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); + +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; + + const wrapper = await mountSuspended( + defineComponent({ + setup() { + useChatTabSetup(); + useIncomingDirectMessages(); + useChatBlocks(); + return () => h("div"); + }, + }), + ); + unmount = () => wrapper.unmount(); + await flushPromises(); + + 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.showAuthors([DANA, EVAN]); + 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", async () => { + setBlocks(DANA); + await flushPromises(); + + socket.emit(`${groupRoom}:reaction`, { + id: "dana-1", + reactions: { heart: [EVAN] }, + }); + + expect(ids(groupMessages())).toEqual(["read", "evan"]); + expect(groupMessages().some((message) => message.reactions)).toBe(false); + }); + + 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("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/utilities/chatLobbyMessages.spec.ts b/tests/utilities/chatLobbyMessages.spec.ts index 27fc3d4d2..170536924 100644 --- a/tests/utilities/chatLobbyMessages.spec.ts +++ b/tests/utilities/chatLobbyMessages.spec.ts @@ -5,9 +5,11 @@ import { chatMessageKey, insertChatMessage, isChatMessageDeleted, + isChatMessageFrom, mergeChatSnapshot, newestMessageIdsFrom, removeChatMessage, + removeChatMessagesFrom, } from "~/utilities/chatLobbyMessages"; import type { LobbyMessage } from "~/web-sockets/Socket"; @@ -42,6 +44,83 @@ describe("removeChatMessage", () => { }); }); +describe("removeChatMessagesFrom", () => { + 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 = removeChatMessagesFrom(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 = removeChatMessagesFrom(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(removeChatMessagesFrom([line("a", 0)], new Set([DANA]))).toBeNull(); + expect(removeChatMessagesFrom([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 = removeChatMessagesFrom( + [system, by("a", 0, DANA)], + new Set([DANA]), + ); + + expect(ids(removed!.messages)).toEqual(["s"]); + }); +}); + +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..21edfbf20 100644 --- a/tests/web-sockets/SocketChatError.spec.ts +++ b/tests/web-sockets/SocketChatError.spec.ts @@ -911,3 +911,137 @@ describe("Socket lobby reactions", () => { expect(ids(lobby.messages)).toEqual(["a", "b"]); }); }); + +describe("Socket hidden authors", () => { + const DANA = "76561198000000003"; + const ELI = "76561198000000004"; + + const by = (id: string, minute: number, steamId: string): LobbyMessage => ({ + ...line(id, minute), + from: { steam_id: steamId }, + }); + + 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: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + }); + socket.emit("lobby:matchmaking:hidden-lobby:messages", { + messages: [line("l1", 0), by("l2", 1, DANA), by("l3", 2, ELI)], + }); + }); + + afterEach(() => { + socket.showAuthors([DANA, ELI]); + match.leave(); + lobby.leave(); + disconnect(); + vi.restoreAllMocks(); + }); + + it("takes their lines out of every open room", () => { + const deleted = vi.fn(); + match.on("lobby:deleted", deleted); + + socket.hideAuthors([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("drops their live lines without telling the room", () => { + const chat = vi.fn(); + match.on("lobby:chat", chat); + socket.hideAuthors([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 out of a snapshot built before the block", () => { + socket.hideAuthors([DANA]); + + socket.emit("lobby:match:hidden-match:messages", { + messages: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + }); + + expect(ids(match.messages)).toEqual(["m2"]); + }); + + it("never brings a hidden line back through an edit or a reaction", () => { + socket.hideAuthors([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(match.messages).toEqual([line("m2", 1)]); + }); + + it("orders a late live line by its timestamp and keeps one copy", () => { + socket.emit("lobby:match:hidden-match:chat", line("m6", 6)); + socket.emit("lobby:match:hidden-match:chat", line("m5", 5)); + socket.emit("lobby:match:hidden-match:chat", line("m6", 6)); + + expect(ids(match.messages)).toEqual(["m1", "m2", "m3", "m5", "m6"]); + }); + + it("asks every open room for its history again on an unblock", () => { + socket.hideAuthors([DANA]); + const send = connect(); + + socket.showAuthors([DANA]); + + const joins = send.mock.calls + .map(([payload]) => JSON.parse(payload)) + .filter(({ event }) => event === "lobby:join") + .map(({ data }) => `${data.type}:${data.id}`); + expect(joins).toEqual( + expect.arrayContaining(["match:hidden-match", "matchmaking:hidden-lobby"]), + ); + + socket.emit("lobby:match:hidden-match:messages", { + messages: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + }); + expect(ids(match.messages)).toEqual(["m1", "m2", "m3"]); + }); + + it("keeps a line deleted while hidden out of the history that comes back", () => { + socket.hideAuthors([DANA]); + socket.emit("lobby:match:hidden-match:deleted", { id: "m3" }); + socket.showAuthors([DANA]); + + socket.emit("lobby:match:hidden-match:messages", { + messages: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + }); + + expect(ids(match.messages)).toEqual(["m1", "m2"]); + }); + + it("rejoins nothing for a player who was not hidden", () => { + const send = connect(); + + socket.showAuthors([ELI]); + + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/utilities/chatLobbyMessages.ts b/utilities/chatLobbyMessages.ts index 72c473e56..ffd2339dc 100644 --- a/utilities/chatLobbyMessages.ts +++ b/utilities/chatLobbyMessages.ts @@ -179,6 +179,49 @@ 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 RemovedChatAuthors { + messages: LobbyMessage[]; + removed: Array>; +} + +// Each index is where the message sat once the ones before it were gone, as if +// they had been deleted one at a time. +export function removeChatMessagesFrom( + current: LobbyMessage[], + authors: ReadonlySet, +): RemovedChatAuthors | null { + if (authors.size === 0) { + return null; + } + + const messages: LobbyMessage[] = []; + const removed: RemovedChatAuthors["removed"] = []; + + for (const message of current) { + if (isChatMessageFrom(message, authors)) { + removed.push({ message, index: messages.length }); + continue; + } + + messages.push(message); + } + + if (removed.length === 0) { + return null; + } + + return { messages, removed }; +} + // 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..2cf8299d0 100644 --- a/web-sockets/Socket.ts +++ b/web-sockets/Socket.ts @@ -14,8 +14,10 @@ import { chatMessageKey, insertChatMessage, isChatMessageDeleted, + isChatMessageFrom, mergeChatSnapshot, removeChatMessage, + removeChatMessagesFrom, type ChatMessageEdit, type ChatMessageReactionsUpdate, type RemovedChatMessage, @@ -135,6 +137,9 @@ export class Socket extends EventEmitter { private instanceCounter = 0; private pendingRequests: Map = new Map(); private reactionsInFlight = new Set(); + // Players this viewer has blocked. The api stops sending their lines once the + // block has committed, but not one already on its way. + private hiddenAuthors = 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 +419,36 @@ export class Socket extends EventEmitter { } } + public hidesAuthor(steamId?: string | number | null) { + return steamId != null && this.hiddenAuthors.has(String(steamId)); + } + + public hideAuthors(steamIds: Array) { + const authors = new Set(steamIds.map(String)); + + for (const steamId of authors) { + this.hiddenAuthors.add(steamId); + } + + for (const lobby of this.lobbies.values()) { + this.removeLobbyAuthors(lobby, authors); + } + } + + // The api sends nothing on an unblock, so the rooms' history is asked for + // again to bring back whatever of theirs is still there. + public showAuthors(steamIds: Array) { + let shown = false; + + for (const steamId of steamIds) { + shown = this.hiddenAuthors.delete(String(steamId)) || shown; + } + + if (shown) { + this.rejoinAll(); + } + } + public event(event: string, data: Record) { if (!this.connected || !this.connection) { this.offlineQueue.push({ event, data }); @@ -785,7 +820,9 @@ export class Socket extends EventEmitter { private mergeLobbyMessages(lobby: LobbyState, messages: LobbyMessage[]) { const merged = mergeChatSnapshot( lobby.messages.value, - messages, + (messages || []).filter( + (message) => !isChatMessageFrom(message, this.hiddenAuthors), + ), lobby.deleted, lobby.reacted, ); @@ -801,7 +838,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); @@ -874,6 +915,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 removeLobbyAuthors(lobby: LobbyState, authors: ReadonlySet) { + const removed = removeChatMessagesFrom(lobby.messages.value, authors); + if (!removed) { + return; + } + + lobby.messages.value = removed.messages; + + for (const event of removed.removed) { + this.emitToLobbyInstances(lobby, "lobby:deleted", event); + } + } + private emitToLobbyInstances( lobby: LobbyState, event: string, From dc733578cee6544d0b4e7e6d3a7a06320bd39a7a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 19:58:13 -0400 Subject: [PATCH 2/3] bug: chat blocks review fixes --- composables/useChatBlocks.ts | 25 ++--- composables/useChatTabSetup.ts | 9 ++ pages/chat/[tabId].vue | 19 +++- tests/components/ChatLobby.spec.ts | 35 +++++- tests/composables/useChatBlocks.spec.ts | 84 +++++++++++--- tests/pages/chat/popout.spec.ts | 126 +++++++++++++++++++++ tests/utilities/chatLobbyMessages.spec.ts | 51 +++++++-- tests/web-sockets/SocketChatError.spec.ts | 131 ++++++++++++++++------ utilities/chatLobbyMessages.ts | 58 ++++++++-- web-sockets/Socket.ts | 68 +++++------ 10 files changed, 490 insertions(+), 116 deletions(-) create mode 100644 tests/pages/chat/popout.spec.ts diff --git a/composables/useChatBlocks.ts b/composables/useChatBlocks.ts index b5b6cc5e8..05e47e045 100644 --- a/composables/useChatBlocks.ts +++ b/composables/useChatBlocks.ts @@ -3,7 +3,6 @@ import { useChatTabs } from "~/composables/useChatTabs"; import { directRoomId, directTabId } from "~/composables/useDirectMessages"; import { usePlayerBlocks } from "~/composables/usePlayerBlocks"; import { useAuthStore } from "~/stores/AuthStore"; -import { blockedIdsChange } from "~/utilities/playerBlocks"; import socket from "~/web-sockets/Socket"; // The api sends no event on a block or an unblock, only changes what it sends @@ -14,8 +13,6 @@ export function useChatBlocks() { const { closeTab } = useChatTabs(); const authStore = useAuthStore(); - let applied = new Set(); - watch( () => (loaded.value ? blocks.value : null), (rows) => { @@ -23,23 +20,17 @@ export function useChatBlocks() { return; } - const next = new Set(rows.map((row) => String(row.blocked_steam_id))); - const { added, removed } = blockedIdsChange(applied, next); - applied = next; - - if (added.length > 0) { - socket.hideAuthors(added); + const { added } = socket.setHiddenAuthors( + rows.map((row) => String(row.blocked_steam_id)), + ); - const mySteamId = authStore.me?.steam_id; - if (mySteamId) { - for (const steamId of added) { - closeTab(directTabId(directRoomId(mySteamId, steamId))); - } - } + const mySteamId = authStore.me?.steam_id; + if (!mySteamId) { + return; } - if (removed.length > 0) { - socket.showAuthors(removed); + 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/pages/chat/[tabId].vue b/pages/chat/[tabId].vue index d938523ef..65dea95df 100644 --- a/pages/chat/[tabId].vue +++ b/pages/chat/[tabId].vue @@ -9,6 +9,8 @@ import { 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"; @@ -81,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 diff --git a/tests/components/ChatLobby.spec.ts b/tests/components/ChatLobby.spec.ts index 4944cd4fd..691976d8d 100644 --- a/tests/components/ChatLobby.spec.ts +++ b/tests/components/ChatLobby.spec.ts @@ -323,7 +323,7 @@ describe("ChatLobby blocked authors", () => { const THIRD = "76561198000000003"; afterEach(() => { - socket.showAuthors([OTHER]); + socket.setHiddenAuthors([]); playNotificationSound.mockClear(); }); @@ -334,7 +334,7 @@ describe("ChatLobby blocked authors", () => { lobbyId: "blocked-live", playNotificationSound: true, }); - socket.hideAuthors([OTHER]); + socket.setHiddenAuthors([OTHER]); socket.emit("lobby:match:blocked-live:chat", line("hidden", 0)); await flushPromises(); @@ -372,11 +372,40 @@ describe("ChatLobby blocked authors", () => { const lobby = wrapper.vm as any; lobby.lastReadMessageCount = 4; - socket.hideAuthors([OTHER]); + 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/composables/useChatBlocks.spec.ts b/tests/composables/useChatBlocks.spec.ts index 1490f904d..380583f83 100644 --- a/tests/composables/useChatBlocks.spec.ts +++ b/tests/composables/useChatBlocks.spec.ts @@ -7,6 +7,7 @@ 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"; @@ -97,6 +98,19 @@ 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}`; @@ -124,18 +138,11 @@ beforeEach(async () => { role: "user", } as any; - const wrapper = await mountSuspended( - defineComponent({ - setup() { - useChatTabSetup(); - useIncomingDirectMessages(); - useChatBlocks(); - return () => h("div"); - }, - }), - ); - unmount = () => wrapper.unmount(); - await flushPromises(); + await mount(() => { + useChatTabSetup(); + useIncomingDirectMessages(); + useChatBlocks(); + }); setBlocks(); @@ -173,7 +180,7 @@ afterEach(async () => { unmount = undefined; useAuthStore().me = undefined; await flushPromises(); - socket.showAuthors([DANA, EVAN]); + socket.setHiddenAuthors([]); disconnect(); useChatTabs().clearAll(); vi.unstubAllGlobals(); @@ -205,7 +212,7 @@ describe("useChatBlocks", () => { expect(unread(groupTab)).toBe(2); }); - it("ignores a reaction to a line it hid", async () => { + it("ignores a reaction to a line it hid, and hides their reactions", async () => { setBlocks(DANA); await flushPromises(); @@ -213,9 +220,16 @@ describe("useChatBlocks", () => { 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().some((message) => message.reactions)).toBe(false); + expect(groupMessages().map((message) => message.reactions)).toEqual([ + undefined, + { fire: [ME] }, + ]); }); it("never reopens the conversation from a message already on its way", async () => { @@ -264,6 +278,46 @@ describe("useChatBlocks", () => { 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(); 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 170536924..5e74f03e4 100644 --- a/tests/utilities/chatLobbyMessages.spec.ts +++ b/tests/utilities/chatLobbyMessages.spec.ts @@ -3,13 +3,14 @@ import { applyChatMessageEdit, applyChatMessageReactions, chatMessageKey, + hideChatAuthors, insertChatMessage, isChatMessageDeleted, isChatMessageFrom, mergeChatSnapshot, newestMessageIdsFrom, removeChatMessage, - removeChatMessagesFrom, + withoutChatReactors, } from "~/utilities/chatLobbyMessages"; import type { LobbyMessage } from "~/web-sockets/Socket"; @@ -44,7 +45,7 @@ describe("removeChatMessage", () => { }); }); -describe("removeChatMessagesFrom", () => { +describe("hideChatAuthors", () => { const DANA = "76561198000000002"; const EVAN = "76561198000000003"; @@ -62,7 +63,7 @@ describe("removeChatMessagesFrom", () => { line("e", 4), ]; - const removed = removeChatMessagesFrom(messages, new Set([DANA, EVAN])); + const removed = hideChatAuthors(messages, new Set([DANA, EVAN])); expect(ids(removed!.messages)).toEqual(["b", "e"]); expect( @@ -77,7 +78,7 @@ describe("removeChatMessagesFrom", () => { 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 = removeChatMessagesFrom(messages, new Set([DANA]))!; + const removed = hideChatAuthors(messages, new Set([DANA]))!; let current = messages; for (const { message, index } of removed.removed) { @@ -90,20 +91,56 @@ describe("removeChatMessagesFrom", () => { }); it("returns null when none of the lines are theirs", () => { - expect(removeChatMessagesFrom([line("a", 0)], new Set([DANA]))).toBeNull(); - expect(removeChatMessagesFrom([by("a", 0, DANA)], new Set())).toBeNull(); + 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 = removeChatMessagesFrom( + 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", () => { diff --git a/tests/web-sockets/SocketChatError.spec.ts b/tests/web-sockets/SocketChatError.spec.ts index 21edfbf20..497545d44 100644 --- a/tests/web-sockets/SocketChatError.spec.ts +++ b/tests/web-sockets/SocketChatError.spec.ts @@ -912,15 +912,47 @@ describe("Socket lobby reactions", () => { }); }); +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; @@ -929,7 +961,7 @@ describe("Socket hidden authors", () => { match = socket.joinLobby("socket-spec", "match", "hidden-match"); lobby = socket.joinLobby("socket-spec", "matchmaking", "hidden-lobby"); socket.emit("lobby:match:hidden-match:messages", { - messages: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + messages: matchHistory(), }); socket.emit("lobby:matchmaking:hidden-lobby:messages", { messages: [line("l1", 0), by("l2", 1, DANA), by("l3", 2, ELI)], @@ -937,18 +969,25 @@ describe("Socket hidden authors", () => { }); afterEach(() => { - socket.showAuthors([DANA, ELI]); + 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.hideAuthors([DANA]); + socket.setHiddenAuthors([DANA]); expect(ids(match.messages)).toEqual(["m2"]); expect(ids(lobby.messages)).toEqual(["l1", "l3"]); @@ -958,10 +997,23 @@ describe("Socket hidden authors", () => { ]); }); + 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.hideAuthors([DANA]); + socket.setHiddenAuthors([DANA]); socket.emit("lobby:match:hidden-match:chat", by("m4", 3, DANA)); socket.emit("lobby:match:hidden-match:chat", line("m5", 4)); @@ -971,18 +1023,36 @@ describe("Socket hidden authors", () => { expect(chat).toHaveBeenCalledWith(line("m5", 4)); }); - it("keeps their lines out of a snapshot built before the block", () => { - socket.hideAuthors([DANA]); + 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: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + 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.hideAuthors([DANA]); + socket.setHiddenAuthors([DANA]); socket.emit("lobby:match:hidden-match:edited", { id: "m1", @@ -994,54 +1064,51 @@ describe("Socket hidden authors", () => { reactions: { heart: [ELI] }, }); - expect(match.messages).toEqual([line("m2", 1)]); - }); - - it("orders a late live line by its timestamp and keeps one copy", () => { - socket.emit("lobby:match:hidden-match:chat", line("m6", 6)); - socket.emit("lobby:match:hidden-match:chat", line("m5", 5)); - socket.emit("lobby:match:hidden-match:chat", line("m6", 6)); - - expect(ids(match.messages)).toEqual(["m1", "m2", "m3", "m5", "m6"]); + expect(ids(match.messages)).toEqual(["m2"]); }); it("asks every open room for its history again on an unblock", () => { - socket.hideAuthors([DANA]); + socket.setHiddenAuthors([DANA]); const send = connect(); - socket.showAuthors([DANA]); + socket.setHiddenAuthors([]); - const joins = send.mock.calls - .map(([payload]) => JSON.parse(payload)) - .filter(({ event }) => event === "lobby:join") - .map(({ data }) => `${data.type}:${data.id}`); - expect(joins).toEqual( - expect.arrayContaining(["match:hidden-match", "matchmaking:hidden-lobby"]), + expect(sentJoins(send)).toEqual( + expect.arrayContaining([ + "match:hidden-match", + "matchmaking:hidden-lobby", + ]), ); socket.emit("lobby:match:hidden-match:messages", { - messages: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + 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.hideAuthors([DANA]); + socket.setHiddenAuthors([DANA]); socket.emit("lobby:match:hidden-match:deleted", { id: "m3" }); - socket.showAuthors([DANA]); + socket.setHiddenAuthors([]); socket.emit("lobby:match:hidden-match:messages", { - messages: [by("m1", 0, DANA), line("m2", 1), by("m3", 2, DANA)], + messages: matchHistory(), }); expect(ids(match.messages)).toEqual(["m1", "m2"]); }); - it("rejoins nothing for a player who was not hidden", () => { + it("rejoins nothing when nobody left the set", () => { + socket.setHiddenAuthors([DANA]); const send = connect(); - socket.showAuthors([ELI]); + socket.setHiddenAuthors([DANA, ELI]); + socket.setHiddenAuthors([DANA, ELI]); - expect(send).not.toHaveBeenCalled(); + expect(sentJoins(send)).toEqual([]); }); }); diff --git a/utilities/chatLobbyMessages.ts b/utilities/chatLobbyMessages.ts index ffd2339dc..65d7e28bf 100644 --- a/utilities/chatLobbyMessages.ts +++ b/utilities/chatLobbyMessages.ts @@ -188,23 +188,25 @@ export function isChatMessageFrom( return steamId != null && authors.has(String(steamId)); } -export interface RemovedChatAuthors { +export interface HiddenChatAuthors { messages: LobbyMessage[]; removed: Array>; } -// Each index is where the message sat once the ones before it were gone, as if -// they had been deleted one at a time. -export function removeChatMessagesFrom( +// 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, -): RemovedChatAuthors | null { +): HiddenChatAuthors | null { if (authors.size === 0) { return null; } const messages: LobbyMessage[] = []; - const removed: RemovedChatAuthors["removed"] = []; + const removed: HiddenChatAuthors["removed"] = []; + let changed = false; for (const message of current) { if (isChatMessageFrom(message, authors)) { @@ -212,16 +214,56 @@ export function removeChatMessagesFrom( continue; } - messages.push(message); + const reactions = withoutChatReactors(message?.reactions, authors); + if (reactions === message?.reactions) { + messages.push(message); + continue; + } + + changed = true; + messages.push({ ...message, reactions }); } - if (removed.length === 0) { + 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/web-sockets/Socket.ts b/web-sockets/Socket.ts index 2cf8299d0..96448b4f5 100644 --- a/web-sockets/Socket.ts +++ b/web-sockets/Socket.ts @@ -12,17 +12,19 @@ import { applyChatMessageEdit, applyChatMessageReactions, chatMessageKey, + hideChatAuthors, insertChatMessage, isChatMessageDeleted, isChatMessageFrom, mergeChatSnapshot, removeChatMessage, - removeChatMessagesFrom, + 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"; @@ -137,9 +139,10 @@ export class Socket extends EventEmitter { private instanceCounter = 0; private pendingRequests: Map = new Map(); private reactionsInFlight = new Set(); - // Players this viewer has blocked. The api stops sending their lines once the - // block has committed, but not one already on its way. - private hiddenAuthors = 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 @@ -423,30 +426,27 @@ export class Socket extends EventEmitter { return steamId != null && this.hiddenAuthors.has(String(steamId)); } - public hideAuthors(steamIds: Array) { - const authors = new Set(steamIds.map(String)); - - for (const steamId of authors) { - this.hiddenAuthors.add(steamId); - } - - for (const lobby of this.lobbies.values()) { - this.removeLobbyAuthors(lobby, authors); - } - } - - // The api sends nothing on an unblock, so the rooms' history is asked for - // again to bring back whatever of theirs is still there. - public showAuthors(steamIds: Array) { - let shown = false; - - for (const steamId of steamIds) { - shown = this.hiddenAuthors.delete(String(steamId)) || shown; + // 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); + } } - if (shown) { + // 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) { @@ -818,11 +818,10 @@ export class Socket extends EventEmitter { } private mergeLobbyMessages(lobby: LobbyState, messages: LobbyMessage[]) { + const snapshot = messages || []; const merged = mergeChatSnapshot( lobby.messages.value, - (messages || []).filter( - (message) => !isChatMessageFrom(message, this.hiddenAuthors), - ), + hideChatAuthors(snapshot, this.hiddenAuthors)?.messages ?? snapshot, lobby.deleted, lobby.reacted, ); @@ -876,7 +875,10 @@ export class Socket extends EventEmitter { ) { const messages = applyChatMessageReactions( lobby.messages.value, - update, + { + ...update, + reactions: withoutChatReactors(update?.reactions, this.hiddenAuthors), + }, lobby.deleted, ); @@ -916,15 +918,15 @@ export class Socket extends EventEmitter { } // No tombstones: an unblock brings these back with the room's history. - private removeLobbyAuthors(lobby: LobbyState, authors: ReadonlySet) { - const removed = removeChatMessagesFrom(lobby.messages.value, authors); - if (!removed) { + private hideLobbyAuthors(lobby: LobbyState, authors: ReadonlySet) { + const hidden = hideChatAuthors(lobby.messages.value, authors); + if (!hidden) { return; } - lobby.messages.value = removed.messages; + lobby.messages.value = hidden.messages; - for (const event of removed.removed) { + for (const event of hidden.removed) { this.emitToLobbyInstances(lobby, "lobby:deleted", event); } } From 883f7e7b611418d8b4951e56bccfe3d46c53161a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 21:25:42 -0400 Subject: [PATCH 3/3] bug: a blocked player's direct message already on its way neither flashes nor badges --- composables/useIncomingDirectMessages.ts | 12 ++++++++++-- tests/components/ChatPanel.spec.ts | 1 + .../useIncomingDirectMessages.spec.ts | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index fae6f4ff6..d36cd63b3 100644 --- a/composables/useIncomingDirectMessages.ts +++ b/composables/useIncomingDirectMessages.ts @@ -118,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); @@ -125,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/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/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();