diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d82aaba4..34984bf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - run: yarn test:auth-outage - run: yarn test:focus-steal - run: yarn test:dm-directory + # Which conversations this device has hidden, and what brings one back. + # Hiding never leaves the device, so nothing else would notice it breaking. + - run: yarn test:hidden-conversations - run: yarn test:new-message - run: yarn test:server-hover-address - run: yarn test:ui-scale diff --git a/e2e/tests/hidden-conversations.spec.ts b/e2e/tests/hidden-conversations.spec.ts new file mode 100644 index 00000000..8cee6fe6 --- /dev/null +++ b/e2e/tests/hidden-conversations.spec.ts @@ -0,0 +1,220 @@ +import type { Locator, Page } from "@playwright/test"; + +import { composer, type Member, membersPanel, messageRow, unique } from "../support/app"; +import { expect, test, uniqueName } from "../support/fixtures"; + +/** + * Hiding a conversation, which is this device's own: nothing about it is sent + * to the server, and the row comes back when somebody writes (GRYT-1379). + */ + +function list(page: Page): Locator { + return page.locator('[data-gryt="dm-list"]'); +} + +/** The rail's button is a toggle and the space is already open when a DM was + opened from the member list, so this presses until the list is there. */ +async function openMessages(page: Page) { + /* The rail's icon and, on a phone, the sheet's row carrying an unread count. + Whichever of the two is on screen is the one to press. */ + const button = page.getByRole("button", { name: /^Direct messages/ }).filter({ visible: true }).first(); + await expect(button).toBeVisible(); + await expect + .poll(async () => { + if (await list(page).isVisible()) return true; + await button.click(); + return list(page).isVisible(); + }) + .toBe(true); +} + +function hiddenToggle(page: Page): Locator { + return list(page).getByRole("button", { name: /^Hidden, / }); +} + +/* Not an exact name: the row carries the server's icon and the time beside the + title, and all three are part of what a screen reader reads out. */ +function row(page: Page, name: string): Locator { + return list(page).getByRole("button", { name }); +} + +function hiddenRow(page: Page, name: string): Locator { + return list(page).getByRole("button", { name: `${name}, hidden`, exact: true }); +} + +async function openDm(from: Member, to: Member) { + await membersPanel(from.page).getByRole("button", { name: to.name, exact: true }).click(); + await expect(composer(from.page, `Message ${to.name}`)).toBeVisible(); +} + +/** The other end, who has the conversation already and a badge on the member row. */ +async function openFromList(page: Page, name: string) { + await openMessages(page); + await row(page, name).click(); + await expect(composer(page, `Message ${name}`)).toBeVisible(); +} + +async function say(page: Page, to: string, text: string) { + const box = composer(page, `Message ${to}`); + await box.click(); + await page.keyboard.insertText(text); + await box.press("Enter"); + await expect(messageRow(page, text)).toBeVisible(); +} + +/** A conversation with something in it, which is what the list draws. */ +async function conversationBetween(a: Member, b: Member): Promise { + await openDm(a, b); + await say(a.page, b.name, unique("hello")); +} + +async function hide(page: Page, name: string) { + await row(page, name).click({ button: "right" }); + await page.getByRole("menuitem", { name: /Hide this conversation/ }).click(); +} + +test("hides a conversation and offers it straight back", async ({ newMember }) => { + const alice = await newMember(); + const bob = await newMember(); + await conversationBetween(bob, alice); + + await openMessages(bob.page); + // Nothing hidden, so there is no group to unfold. + await expect(hiddenToggle(bob.page)).toHaveCount(0); + + await hide(bob.page, alice.name); + + await expect(row(bob.page, alice.name)).toHaveCount(0); + await expect(hiddenToggle(bob.page)).toBeVisible(); + await expect(hiddenToggle(bob.page)).toHaveAttribute("aria-expanded", "false"); + + // Everything hidden: the toggle is the only row left in the list. + await expect(list(bob.page).getByRole("button", { name: new RegExp(alice.name) })).toHaveCount(0); + + await bob.page.getByRole("button", { name: "Undo" }).click(); + await expect(row(bob.page, alice.name)).toBeVisible(); + await expect(hiddenToggle(bob.page)).toHaveCount(0); +}); + +test("keeps a hidden conversation readable, and puts it back from its own menu", async ({ newMember }) => { + const alice = await newMember(); + const bob = await newMember(); + await conversationBetween(bob, alice); + + await openMessages(bob.page); + await hide(bob.page, alice.name); + + const toggle = hiddenToggle(bob.page); + await expect(toggle).toContainText("Hidden"); + await expect(toggle).toContainText("1"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + + // Drawn back, and named as hidden for anybody who cannot see that it is. + const dimmed = hiddenRow(bob.page, alice.name); + await expect(dimmed).toBeVisible(); + await expect(dimmed).toHaveCSS("opacity", "0.55"); + + // It opens and reads like any other conversation, without coming back. + await dimmed.click(); + await expect(composer(bob.page, `Message ${alice.name}`)).toBeVisible(); + await expect(hiddenToggle(bob.page)).toBeVisible(); + + await hiddenRow(bob.page, alice.name).click({ button: "right" }); + await bob.page.getByRole("menuitem", { name: "Show in list" }).click(); + + await expect(row(bob.page, alice.name)).toBeVisible(); + await expect(hiddenToggle(bob.page)).toHaveCount(0); +}); + +test("comes back when they write, including while the app was shut", async ({ newMember }) => { + const alice = await newMember(); + const bob = await newMember(); + await conversationBetween(bob, alice); + await openFromList(alice.page, bob.name); + + await openMessages(bob.page); + await hide(bob.page, alice.name); + await expect(hiddenToggle(bob.page)).toBeVisible(); + + await say(alice.page, bob.name, unique("still here?")); + + // Back in the list on its own, and the group it was in goes with it. + await expect(row(bob.page, alice.name)).toBeVisible(); + await expect(hiddenToggle(bob.page)).toHaveCount(0); + + // Again, with nothing listening: the answer is in the list the server sends. + await hide(bob.page, alice.name); + await expect(hiddenToggle(bob.page)).toBeVisible(); + await bob.page.close(); + + await say(alice.page, bob.name, unique("and again")); + + const back = await bob.context.newPage(); + await back.goto("/"); + await openMessages(back); + await expect(row(back, alice.name)).toBeVisible(); + await expect(hiddenToggle(back)).toHaveCount(0); + await back.close(); +}); + +test("is this device's answer, and nobody else's", async ({ newMember }) => { + const alice = await newMember(); + const bob = await newMember(); + await conversationBetween(bob, alice); + + await openMessages(bob.page); + await hide(bob.page, alice.name); + await expect(hiddenToggle(bob.page)).toBeVisible(); + + // Alice was never told, because nothing was sent. Her list is untouched. + await openMessages(alice.page); + await expect(row(alice.page, bob.name)).toBeVisible(); + await expect(hiddenToggle(alice.page)).toHaveCount(0); +}); + +test("fits a phone, with the count at the edge and the name cut short", async ({ newMember }) => { + const alice = await newMember({ nickname: uniqueName("Aurora-Borealis-Nordlys-Very-Long") }); + const bob = await newMember({ phone: true }); + // From Alice's side: a phone has no member list to click through. + await openDm(alice, bob); + await say(alice.page, bob.name, unique("hello")); + + /* The phone's sheet holds the list, and opening the space closes it, so the + way in is: sheet, Messages, sheet again. */ + const sheet = () => bob.page.getByRole("button", { name: "Open channels" }).click(); + await sheet(); + // By its text, which the rail's icon button does not have. + await bob.page.getByRole("button", { name: /^Direct messages/ }).filter({ hasText: "Direct messages" }).click(); + await sheet(); + await expect(list(bob.page)).toBeVisible(); + + await hide(bob.page, alice.name); + + const toggle = hiddenToggle(bob.page); + await expect(toggle).toBeVisible(); + const edge = await toggle.evaluate((el) => { + const parent = el.parentElement as HTMLElement; + const count = el.querySelector("span.tabular-nums") as HTMLElement; + return { + fits: el.getBoundingClientRect().right <= parent.getBoundingClientRect().right + 1, + countFromRight: parent.getBoundingClientRect().right - count.getBoundingClientRect().right, + }; + }); + expect(edge.fits, "the Hidden row ran past the list it is in").toBe(true); + // Tucked against the right edge, beside the caret rather than after the word. + expect(edge.countFromRight).toBeLessThan(40); + + await toggle.click(); + const dimmed = hiddenRow(bob.page, alice.name); + await expect(dimmed).toBeVisible(); + const cut = await dimmed.evaluate((el) => { + const name = el.querySelector("span.truncate") as HTMLElement; + return { + truncated: name.scrollWidth > name.clientWidth, + inside: el.getBoundingClientRect().right <= window.innerWidth + 1, + }; + }); + expect(cut.truncated, "a name too long for the row was not cut short").toBe(true); + expect(cut.inside, "the hidden row ran off the side of the phone").toBe(true); +}); diff --git a/package.json b/package.json index ad2232d7..76e99d58 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "test:auth-outage": "node scripts/check-auth-outage.mjs", "test:focus-steal": "node scripts/check-focus-steal.mjs", "test:dm-directory": "node scripts/check-dm-directory.mjs", + "test:hidden-conversations": "node --disable-warning=ExperimentalWarning scripts/check-hidden-conversations.mjs", "test:new-message": "node scripts/check-new-message.mjs", "test:server-hover-address": "node scripts/check-server-hover-address.mjs", "test:ui-scale": "node scripts/check-ui-scale.mjs", diff --git a/scripts/check-hidden-conversations.mjs b/scripts/check-hidden-conversations.mjs new file mode 100644 index 00000000..f19ddbd7 --- /dev/null +++ b/scripts/check-hidden-conversations.mjs @@ -0,0 +1,192 @@ +/* eslint-env node */ + +/** + * Which conversations are hidden, given what this device stored and what the + * server last sent. Hiding never leaves the device, so nothing else checks it. + */ + +import assert from "node:assert/strict"; + +/* A store, since the module reads and writes one at import time and node has + none. Kept deliberately dumb: the point is the rules above it. */ +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; + +const { + hideConversation, + hiddenFor, + isBackFromHiding, + resetHiddenConversations, + showConversation, + splitHidden, +} = await import("../src/packages/socket/src/hooks/hiddenConversations.ts"); + +const HOST = "a:5001"; +const ME = "server-user-me"; + +const entry = (id, at, host = HOST) => ({ + host, + conversation: { conversation_id: id, last_message_at: at }, +}); + +const split = (entries, now) => + splitHidden(entries, (host) => hiddenFor(host, ME), now); +const idsOf = (rows) => rows.map((r) => r.conversation.conversation_id); + +// ── nothing hidden ───────────────────────────────────────────────────────── +resetHiddenConversations(); +store.clear(); +{ + const rows = [entry("dm_1", "2026-09-20T10:00:00Z"), entry("dm_2", null)]; + const { listed, hidden, returned } = split(rows, Date.parse("2026-09-21T00:00:00Z")); + assert.deepEqual(idsOf(listed), ["dm_1", "dm_2"]); + assert.deepEqual(hidden, []); + assert.deepEqual(returned, []); +} + +// ── hidden, and still hidden ─────────────────────────────────────────────── +{ + const hiddenAt = Date.parse("2026-09-21T00:00:00Z"); + hideConversation(HOST, ME, "dm_1", hiddenAt); + + const rows = [entry("dm_1", "2026-09-20T10:00:00Z"), entry("dm_2", null)]; + const { listed, hidden } = split(rows, hiddenAt + 1000); + assert.deepEqual(idsOf(listed), ["dm_2"], "the hidden row stayed in the list"); + assert.deepEqual(idsOf(hidden), ["dm_1"]); +} + +// ── a message after it was hidden brings it back ─────────────────────────── +{ + const hiddenAt = Date.parse("2026-09-21T00:00:00Z"); + const rows = [entry("dm_1", "2026-09-21T09:00:00Z")]; + const { listed, hidden, returned } = split(rows, hiddenAt + 1000); + assert.deepEqual(idsOf(listed), ["dm_1"], "a newer message did not bring it back"); + assert.deepEqual(hidden, []); + assert.deepEqual(idsOf(returned), ["dm_1"], "the stored entry was not offered up for dropping"); +} + +/* The same instant is not "after": the message the conversation was hidden on + would otherwise put it straight back. */ +{ + const hiddenAt = Date.parse("2026-09-21T00:00:00Z"); + assert.equal(isBackFromHiding(hiddenAt, "2026-09-21T00:00:00Z", hiddenAt + 1000), false); + assert.equal(isBackFromHiding(hiddenAt, "2026-09-21T00:00:00.001Z", hiddenAt + 1000), true); +} + +// ── a conversation nobody has written in ─────────────────────────────────── +{ + // Nothing to compare against, so it stays hidden until somebody writes. + assert.equal(isBackFromHiding(Date.parse("2026-09-21T00:00:00Z"), null), false); + assert.equal(isBackFromHiding(Date.parse("2026-09-21T00:00:00Z"), undefined), false); + assert.equal(isBackFromHiding(Date.parse("2026-09-21T00:00:00Z"), "not a date"), false); +} + +/* ── a clock that went backwards ──────────────────────────────────────────── + Hidden at a time that is now in the future. Without the clamp the stored + moment beats every message ever sent, and the row never comes back. */ +{ + const now = Date.parse("2026-09-21T00:00:00Z"); + const hiddenAt = Date.parse("2027-01-01T00:00:00Z"); + assert.equal( + isBackFromHiding(hiddenAt, "2026-09-21T00:00:01Z", now), + true, + "a clock that went backwards left it hidden for good", + ); + // A message from before now still leaves it hidden, clock or no clock. + assert.equal(isBackFromHiding(hiddenAt, "2026-09-20T23:59:59Z", now), false); +} + +// ── put back by hand ─────────────────────────────────────────────────────── +{ + resetHiddenConversations(); + store.clear(); + const hiddenAt = Date.parse("2026-09-21T00:00:00Z"); + hideConversation(HOST, ME, "dm_1", hiddenAt); + assert.deepEqual(Object.keys(hiddenFor(HOST, ME)), ["dm_1"]); + + showConversation(HOST, ME, "dm_1"); + assert.deepEqual(hiddenFor(HOST, ME), {}, "putting it back left the entry behind"); + + const { listed, hidden } = split([entry("dm_1", null)], hiddenAt + 1000); + assert.deepEqual(idsOf(listed), ["dm_1"]); + assert.deepEqual(hidden, []); +} + +/* ── one account's list is its own ────────────────────────────────────────── + Two servers, and the same person signed in twice. Neither reads the other. */ +{ + resetHiddenConversations(); + store.clear(); + const hiddenAt = Date.parse("2026-09-21T00:00:00Z"); + hideConversation(HOST, ME, "dm_1", hiddenAt); + + assert.deepEqual(hiddenFor("b:5001", ME), {}, "another host saw this host's list"); + assert.deepEqual(hiddenFor(HOST, "somebody-else"), {}, "another account on the same host saw it"); + + const rows = [entry("dm_1", null), entry("dm_1", null, "b:5001")]; + const { listed, hidden } = split(rows, hiddenAt + 1000); + assert.deepEqual(idsOf(hidden), ["dm_1"]); + assert.equal(listed.length, 1, "the same id on another server went with it"); + assert.equal(listed[0].host, "b:5001"); +} + +// ── what was hidden survives a restart ───────────────────────────────────── +{ + const hiddenAt = Date.parse("2026-09-21T00:00:00Z"); + resetHiddenConversations(); + assert.deepEqual(Object.keys(hiddenFor(HOST, ME)), ["dm_1"], "it was not read back from storage"); + assert.equal(hiddenFor(HOST, ME).dm_1, hiddenAt, "the moment it was hidden did not survive"); +} + +/* ── storage that throws ──────────────────────────────────────────────────── + A private window, or a browser with site data blocked. The list has to draw. */ +{ + const kept = globalThis.localStorage; + globalThis.localStorage = { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + removeItem: () => { + throw new Error("denied"); + }, + }; + + resetHiddenConversations(); + assert.deepEqual(hiddenFor(HOST, ME), {}, "a reading failure was not survivable"); + + const now = Date.parse("2026-09-21T00:00:00Z"); + hideConversation(HOST, ME, "dm_1", now); + assert.deepEqual(Object.keys(hiddenFor(HOST, ME)), ["dm_1"], "a write that threw lost the session"); + const { hidden } = split([entry("dm_1", null)], now + 1000); + assert.deepEqual(idsOf(hidden), ["dm_1"]); + + globalThis.localStorage = kept; +} + +/* ── stored nonsense ──────────────────────────────────────────────────────── + Somebody else's key, or a half-written one. Read as nothing hidden. */ +{ + resetHiddenConversations(); + store.clear(); + store.set(`gryt:hiddenConversations:${HOST}:${ME}`, "[not json"); + assert.deepEqual(hiddenFor(HOST, ME), {}); + + resetHiddenConversations(); + store.set(`gryt:hiddenConversations:${HOST}:${ME}`, '["dm_1"]'); + assert.deepEqual(hiddenFor(HOST, ME), {}, "an array was read as a hidden list"); + + resetHiddenConversations(); + store.set(`gryt:hiddenConversations:${HOST}:${ME}`, '{"dm_1":"yesterday","dm_2":5}'); + assert.deepEqual(hiddenFor(HOST, ME), { dm_2: 5 }, "a value that is not a number was kept"); +} + +console.log( + "hidden conversations: hidden per host and account, back on a newer message, and safe without storage", +); diff --git a/src/packages/socket/src/components/DirectMessageList.tsx b/src/packages/socket/src/components/DirectMessageList.tsx index d3edd7dc..fda7bfcb 100644 --- a/src/packages/socket/src/components/DirectMessageList.tsx +++ b/src/packages/socket/src/components/DirectMessageList.tsx @@ -52,7 +52,7 @@ export const DirectMessageList = ({ /** Unseen mentions per conversation id. Absent means none. */ mentionCounts?: Map; onSelect: (conversation: DirectConversation) => void; - /** Take it out of this person's own list. Absent on a server without it. */ + /** Take it out of this device's list. Absent where there is no hiding. */ onHide?: (conversation: DirectConversation) => void; /** Open a group's settings. Only passed for the groups section. */ onManage?: (conversation: DirectConversation) => void; @@ -170,10 +170,9 @@ export const DirectMessageList = ({
Hide this conversation {/* Says what it does not do. "Hide" on its own reads as - a soft delete to enough people that the second line - earns its space. */} + a soft delete, and since GRYT-1379 it is per device. */} - Keeps the messages. Comes back if they write. + Only on this device. Comes back if they write.
diff --git a/src/packages/socket/src/components/DmSpaceList.tsx b/src/packages/socket/src/components/DmSpaceList.tsx index 91ce7da7..bb2e4bac 100644 --- a/src/packages/socket/src/components/DmSpaceList.tsx +++ b/src/packages/socket/src/components/DmSpaceList.tsx @@ -1,4 +1,4 @@ -import { Avatar, Button } from "@gryt/ui"; +import { Avatar, Button, ContextMenu } from "@gryt/ui"; import { getUploadsFileUrl, resolveAvatarSrc, serverIconSrc } from "@/common"; @@ -7,6 +7,7 @@ import { conversationTitle, type DirectConversation } from "../hooks/useDirectMe import { useServerManagement } from "../hooks/useServerManagement"; import { useSockets } from "../hooks/useSockets"; import { EmojiText } from "./EmojiText"; +import { MarkAsReadItem } from "./MarkAsReadItem"; import { ServerMark } from "./ServerChip"; import { UnreadIndicator } from "./UnreadIndicator"; @@ -27,98 +28,155 @@ function when(iso: string | null): string { } function ConversationRow({ - entry, serverName, serverIcon, unread, selected, onOpen, + entry, serverName, serverIcon, unread, selected, hidden, onOpen, onHide, onShow, }: { entry: DirectoryEntry; serverName: string; serverIcon: string; unread: number; selected: boolean; + /** Drawn back and named as hidden. It still opens and reads normally. */ + hidden?: boolean; onOpen: () => void; + onHide?: () => void; + onShow?: () => void; }) { const { host, conversation } = entry; const title = conversationTitle(conversation); const isGroup = conversation.kind === "group"; return ( - + + + {when(conversation.last_message_at)} + + + + + + + + + + + + {title} + + + {onShow && ( + <> + + Show in list + + )} + {onHide && ( + <> + + +
+ Hide this conversation + {/* Two things people assume it does and it doesn't: leave, + and follow you to your other devices. */} + + Only on this device. Comes back if they write. + +
+
+ + )} +
+
+
+ ); } export function DmSpaceList({ - entries, selected, unreadFor, onOpen, + entries, selected, unreadFor, onOpen, onHide, onShow, hidden = false, }: { entries: DirectoryEntry[]; selected: { host: string; conversationId: string } | null; unreadFor: (host: string, conversationId: string) => number; onOpen: (host: string, conversation: DirectConversation) => void; + /** Take it out of this device's list. Absent on the hidden rows. */ + onHide?: (entry: DirectoryEntry) => void; + /** Put it back. Only passed for the hidden rows. */ + onShow?: (entry: DirectoryEntry) => void; + /** Whether these are the hidden ones, which are drawn back. */ + hidden?: boolean; }) { const { servers } = useServerManagement(); const { serverDetailsList } = useSockets(); return ( -
+
{entries.map((entry) => (
diff --git a/src/packages/socket/src/components/DmSpaceSidebar.tsx b/src/packages/socket/src/components/DmSpaceSidebar.tsx index 9c9becf8..bb68cc64 100644 --- a/src/packages/socket/src/components/DmSpaceSidebar.tsx +++ b/src/packages/socket/src/components/DmSpaceSidebar.tsx @@ -1,26 +1,39 @@ import { conversationTitle, type DirectConversation } from "@gryt/core"; import { IconButton, TextField, Tooltip } from "@gryt/ui"; -import { useMemo, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useMemo, useState } from "react"; -import { PiPlus } from "../../../../lib/icons"; -import { listedConversations, useDirectory } from "../hooks/dmDirectory"; +import { getOwnServerUserId } from "@/common"; + +import { PiCaretDownFill, PiCaretRightFill, PiPlus } from "../../../../lib/icons"; +import { type DirectoryEntry,listedConversations, useDirectory } from "../hooks/dmDirectory"; import { requestConversation, useVisitingConversation, visitConversation } from "../hooks/dmSpace"; +import { + setHiddenExpanded, + showConversation, + splitHidden, + useHiddenConversations, + useHiddenExpanded, +} from "../hooks/hiddenConversations"; import { setNewMessageOpen } from "../hooks/newMessageDialog"; import { useDirectoryUnread } from "../hooks/useDirectoryUnread"; import { useServerManagement } from "../hooks/useServerManagement"; import { DmSpaceList } from "./DmSpaceList"; +import { hideConversationWithUndo } from "./hideConversation"; /** * The conversation list, in place of the channel sidebar. Every server's, not * this one's: it is the only column in Gryt that crosses servers (GRYT-1134). */ export function DmSpaceSidebar({ - host, selectedConversationId, onOpen, fill = false, + host, selectedConversationId, onOpen, onHidden, fill = false, }: { /** The server currently rendered beside this, so a row on it opens directly. */ host: string; selectedConversationId: string | null; onOpen: (conversation: { conversation_id: string }) => void; + /** Told when a row on this server is hidden, so the view can let go of it. */ + onHidden?: (conversationId: string) => void; /** The whole of what it's in, for the phone's sheet and the tiny window, rather than a 300px column. */ fill?: boolean; }) { @@ -28,23 +41,50 @@ export function DmSpaceSidebar({ const { countFor } = useDirectoryUnread(); const { servers, viewServerBehindDmSpace } = useServerManagement(); const [query, setQuery] = useState(""); + const reduceMotion = useReducedMotion(); /* A conversation nobody has written in is listed only while it is the open one, so clicking through a member list leaves no rows behind. */ const visiting = useVisitingConversation(); const listed = useMemo(() => listedConversations(entries, visiting), [entries, visiting]); + /* Hidden is this device's answer for this account, so the split has to happen + here rather than in the store the servers write into. */ + const hiddenAtFor = useHiddenConversations(); + const { listed: visible, hidden, returned } = useMemo( + () => splitHidden(listed, (rowHost) => hiddenAtFor(rowHost, getOwnServerUserId(rowHost) ?? "")), + [listed, hiddenAtFor], + ); + + /* A message brought these back, so the note that they were hidden is spent. + In an effect: the split runs during a render and must not write. */ + useEffect(() => { + for (const entry of returned) { + showConversation(entry.host, getOwnServerUserId(entry.host) ?? "", entry.conversation.conversation_id); + } + }, [returned]); + /* Matched on the name and on the server, since the server is what tells two rows carrying one name apart. */ - const shown = useMemo(() => { + const matching = useMemo(() => { const needle = query.trim().toLowerCase(); - if (!needle) return listed; - return listed.filter((entry) => { + const match = (entry: DirectoryEntry) => { const name = conversationTitle(entry.conversation).toLowerCase(); const server = (servers[entry.host]?.name || entry.host).toLowerCase(); return name.includes(needle) || server.includes(needle); - }); - }, [listed, query, servers]); + }; + if (!needle) return { shown: visible, shownHidden: hidden }; + return { shown: visible.filter(match), shownHidden: hidden.filter(match) }; + }, [visible, hidden, query, servers]); + + /* Open while the conversation being read is one of the hidden ones, whatever + this device last chose: a selected row nobody can see is a lost cursor. */ + const chosenExpanded = useHiddenExpanded(); + const selectedIsHidden = hidden.some( + (entry) => entry.host === host && entry.conversation.conversation_id === selectedConversationId, + ); + const expanded = chosenExpanded || selectedIsHidden; + const Caret = expanded ? PiCaretDownFill : PiCaretRightFill; /* A row on this server opens here. One on another switches first, and the view that arrives claims it. */ @@ -60,8 +100,26 @@ export function DmSpaceSidebar({ viewServerBehindDmSpace(rowHost); } + function hide(entry: DirectoryEntry) { + const conversationId = entry.conversation.conversation_id; + hideConversationWithUndo( + entry.host, + getOwnServerUserId(entry.host) ?? "", + conversationId, + conversationTitle(entry.conversation), + ); + if (entry.host === host) onHidden?.(conversationId); + } + + function show(entry: DirectoryEntry) { + showConversation(entry.host, getOwnServerUserId(entry.host) ?? "", entry.conversation.conversation_id); + } + + const selected = selectedConversationId ? { host, conversationId: selectedConversationId } : null; + return ( ); diff --git a/src/packages/socket/src/components/hideConversation.tsx b/src/packages/socket/src/components/hideConversation.tsx new file mode 100644 index 00000000..c462332c --- /dev/null +++ b/src/packages/socket/src/components/hideConversation.tsx @@ -0,0 +1,48 @@ +import { Button } from "@gryt/ui"; +import toast from "react-hot-toast"; + +import { markChannelRead } from "@/common"; + +import { hideConversation, showConversation } from "../hooks/hiddenConversations"; + +/** + * Hiding, with the way back attached. No confirm: the row is one click from + * being back, and the Hidden group holds it in the meantime. + */ + +/* Long enough to notice what happened and reach for Undo, short enough that it + is gone before the next thing you do. */ +const UNDO_MS = 5_000; + +export function hideConversationWithUndo( + host: string, + serverUserId: string, + conversationId: string, + title: string, +): void { + hideConversation(host, serverUserId, conversationId); + + /* Otherwise a badge counted for a row nobody can see, and the server's own dot + kept pointing at a conversation that is not in the list. */ + markChannelRead(host, conversationId); + + const id = `hidden:${host}:${conversationId}`; + toast( + () => ( + + Hid {title} + + + ), + { id, duration: UNDO_MS }, + ); +} diff --git a/src/packages/socket/src/components/serverView.tsx b/src/packages/socket/src/components/serverView.tsx index 0647abc0..1c472fc5 100644 --- a/src/packages/socket/src/components/serverView.tsx +++ b/src/packages/socket/src/components/serverView.tsx @@ -3,7 +3,7 @@ import { useSFU } from "@gryt/voice"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; -import { clearMentions, clearSignedOut, getUploadsFileUrl, markChannelRead, serverIconSrc, useAccount, useMentionTracker, useThreadMentions, useUnreadTracker } from "@/common"; +import { clearMentions, clearSignedOut, getOwnServerUserId, getUploadsFileUrl, markChannelRead, serverIconSrc, useAccount, useMentionTracker, useThreadMentions, useUnreadTracker } from "@/common"; import { NOTIFICATION_CHANNEL_OPEN_EVENT } from "@/lib/desktopNotification"; import { useIsCompact, useIsMobile } from "@/mobile"; import { useSettings } from "@/settings"; @@ -25,6 +25,7 @@ import { readFakeCallOptions, useFakeCallEvents } from "../dev/fakeServerEvents" import { useFakeSpeech } from "../dev/fakeSpeech"; import { useDirectory } from "../hooks/dmDirectory"; import { conversationFor, conversationOpened, leftOver, rememberConversation, requestConversation, setDmSpaceOpen, usePendingConversation, useVisitingConversation, visitConversation } from "../hooks/dmSpace"; +import { hideConversation, isHiddenConversation } from "../hooks/hiddenConversations"; import { useAdminActions } from "../hooks/useAdminActions"; import { useBlocks } from "../hooks/useBlocks"; import { useCalls } from "../hooks/useCalls"; @@ -52,6 +53,7 @@ import { ConnectionBanner } from "./ConnectionBanner"; import { CreateChannelDialog } from "./CreateChannelDialog"; import { DmSpaceSidebar } from "./DmSpaceSidebar"; import { GroupDialog } from "./GroupDialog"; +import { hideConversationWithUndo } from "./hideConversation"; import { IncomingCallCard } from "./IncomingCallCard"; import { MemberSidebarPanel } from "./MemberSidebarPanel"; import { MobileServerView } from "./MobileServerView"; @@ -268,7 +270,6 @@ export const ServerView = ({ dmSpace = false }: { dmSpace?: boolean }) => { const { conversations: directConversations, openDm, - setHidden: setDmHidden, updateGroup, addToGroup, leaveGroup, @@ -465,6 +466,9 @@ export const ServerView = ({ dmSpace = false }: { dmSpace?: boolean }) => { const stale = leftOver(selected?.conversation, visiting); if (selectedDmId && !stale) return; const recent = directory + /* Otherwise hiding the one being read picks it straight back up, since + the server still sends it and only this device is holding it back. */ + .filter((entry) => !isHiddenConversation(entry.host, getOwnServerUserId(entry.host) ?? "", entry.conversation)) .filter((entry) => entry.conversation.last_message_at !== null) .sort((a, b) => (b.conversation.last_message_at ?? "").localeCompare(a.conversation.last_message_at ?? ""))[0]; @@ -518,12 +522,37 @@ export const ServerView = ({ dmSpace = false }: { dmSpace?: boolean }) => { setSelectedDmId(conversation.conversation_id); }, [setSelectedDmId]); - /* Hiding the one being read points the view at a conversation no longer in the - list, so the selection goes back to the channels. */ + /* Hiding the one being read points the view at a conversation that has moved + under the Hidden group, so the selection goes back to the channels. */ + const forgetHiddenDm = useCallback((conversationId: string) => { + setSelectedDmId((current) => (current === conversationId ? null : current)); + }, [setSelectedDmId]); + const handleHideDm = useCallback((conversation: { conversation_id: string }) => { - setSelectedDmId((current) => (current === conversation.conversation_id ? null : current)); - setDmHidden(conversation.conversation_id, true); - }, [setDmHidden, setSelectedDmId]); + const viewingHost = currentlyViewingServer?.host; + if (!viewingHost) return; + const found = directConversations.find((c) => c.conversation_id === conversation.conversation_id); + hideConversationWithUndo( + viewingHost, + currentServerUserId ?? "", + conversation.conversation_id, + found ? conversationTitle(found) : "the conversation", + ); + forgetHiddenDm(conversation.conversation_id); + }, [currentlyViewingServer, directConversations, forgetHiddenDm, currentServerUserId]); + + /* Blocking used to take the conversation off the list on the server. It is + this device's own now, so the block does the same thing here. GRYT-1379. */ + const blockAndHide = useCallback((targetServerUserId: string) => { + block(targetServerUserId); + const viewingHost = currentlyViewingServer?.host; + const found = directConversations.find( + (c) => c.kind !== "group" && c.other?.server_user_id === targetServerUserId, + ); + if (!viewingHost || !found) return; + hideConversation(viewingHost, currentServerUserId ?? "", found.conversation_id); + forgetHiddenDm(found.conversation_id); + }, [block, currentlyViewingServer, directConversations, forgetHiddenDm, currentServerUserId]); /** The group whose settings are open, or null. Starting one is the + next to Messages, in `NewMessageDialog`. */ @@ -873,7 +902,7 @@ forumTags={activeDm ? [] : activeChannelForumTags}
{/* In the direct messages space, the list and a conversation take turns filling the window. */} {showTinyList ? ( - + ) : chatView} ) : ( - (isBlocked(targetServerUserId) ? unblock : block)(targetServerUserId) + (isBlocked(targetServerUserId) ? unblock : blockAndHide)(targetServerUserId) } onReport={setReportTarget} pinned={pinMembersSidebar} @@ -1177,7 +1207,7 @@ forumTags={activeDm ? [] : activeChannelForumTags} reportUser({ serverUserId, reason }); /* The reporter's own act, so it does not wait on the report landing: on a server too old for `user:report` the block still works. */ - if (alsoBlock) block(serverUserId); + if (alsoBlock) blockAndHide(serverUserId); }} /> diff --git a/src/packages/socket/src/hooks/hiddenConversations.ts b/src/packages/socket/src/hooks/hiddenConversations.ts new file mode 100644 index 00000000..a4c092e5 --- /dev/null +++ b/src/packages/socket/src/hooks/hiddenConversations.ts @@ -0,0 +1,250 @@ +import type { DirectConversation } from "@gryt/core"; +import { useMemo, useSyncExternalStore } from "react"; + +import type { DirectoryEntry } from "./dmDirectory"; + +/** + * Conversations taken out of this device's Messages list. Per device and per + * account, and never sent anywhere: the server let go of this in GRYT-1379. + */ + +const STORAGE_PREFIX = "gryt:hiddenConversations"; +const EXPANDED_KEY = `${STORAGE_PREFIX}:expanded`; + +/** Conversation id to the moment it was hidden, in epoch milliseconds. */ +export type HiddenAt = Readonly>; + +const EMPTY: HiddenAt = Object.freeze({}); + +function storageKey(host: string, serverUserId: string): string { + return `${STORAGE_PREFIX}:${host}:${serverUserId}`; +} + +function readStorage(host: string, serverUserId: string): HiddenAt { + try { + const raw = localStorage.getItem(storageKey(host, serverUserId)); + if (!raw) return EMPTY; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return EMPTY; + + const out: Record = {}; + for (const [id, at] of Object.entries(parsed as Record)) { + if (typeof at === "number" && Number.isFinite(at)) out[id] = at; + } + return Object.freeze(out); + } catch { + // A private window, or storage somebody cleared under us. Nothing hidden + // draws every conversation, which is the safe way to be wrong. + return EMPTY; + } +} + +function writeStorage(host: string, serverUserId: string, hidden: HiddenAt): void { + try { + if (Object.keys(hidden).length === 0) { + localStorage.removeItem(storageKey(host, serverUserId)); + return; + } + localStorage.setItem(storageKey(host, serverUserId), JSON.stringify(hidden)); + } catch { + // Full or unavailable. What was hidden holds for this session and is back + // next launch, which costs somebody a tidy sidebar and nothing else. + } +} + +/* Keyed by host and account together, so two people sharing a device do not + share a list, and the same nickname on two servers is still two rows. */ +let byAccount: ReadonlyMap = new Map(); +const listeners = new Set<() => void>(); + +function accountKey(host: string, serverUserId: string): string { + return JSON.stringify([host, serverUserId]); +} + +function emitChange(): void { + for (const listener of listeners) listener(); +} + +/** What this account has hidden, read from storage the first time it is asked. */ +function load(host: string, serverUserId: string): HiddenAt { + const key = accountKey(host, serverUserId); + const held = byAccount.get(key); + if (held) return held; + + const fromStorage = readStorage(host, serverUserId); + const next = new Map(byAccount); + next.set(key, fromStorage); + byAccount = next; + return fromStorage; +} + +function store(host: string, serverUserId: string, hidden: HiddenAt): void { + const next = new Map(byAccount); + next.set(accountKey(host, serverUserId), hidden); + byAccount = next; + writeStorage(host, serverUserId, hidden); + emitChange(); +} + +/** Takes a conversation out of the list. `at` is only ever passed by a test. */ +export function hideConversation( + host: string, + serverUserId: string, + conversationId: string, + at: number = Date.now(), +): void { + const held = load(host, serverUserId); + if (held[conversationId] === at) return; + store(host, serverUserId, Object.freeze({ ...held, [conversationId]: at })); +} + +/** Puts it back, by hand or because a message brought it back. */ +export function showConversation(host: string, serverUserId: string, conversationId: string): void { + const held = load(host, serverUserId); + if (!(conversationId in held)) return; + const next = { ...held }; + delete next[conversationId]; + store(host, serverUserId, Object.freeze(next)); +} + +/** Everything this account has hidden, for a test or for a render. */ +export function hiddenFor(host: string, serverUserId: string): HiddenAt { + return load(host, serverUserId); +} + +/** + * Whether a message landed after it was hidden, read off the list the server + * already sends. Clamped to now, or a clock that went back hides it for good. + */ +export function isBackFromHiding( + hiddenAt: number, + lastMessageAt: string | null | undefined, + now: number = Date.now(), +): boolean { + if (!lastMessageAt) return false; + const wrote = Date.parse(lastMessageAt); + if (Number.isNaN(wrote)) return false; + return wrote > Math.min(hiddenAt, now); +} + +/** Whether this device is holding this conversation back at the moment. */ +export function isHiddenConversation( + host: string, + serverUserId: string, + conversation: DirectConversation, + now: number = Date.now(), +): boolean { + const at = load(host, serverUserId)[conversation.conversation_id]; + if (at === undefined) return false; + return !isBackFromHiding(at, lastMessageAt(conversation), now); +} + +export interface SplitConversations { + /** The rows the list draws, in the order they came in. */ + listed: DirectoryEntry[]; + /** The rows under the Hidden toggle, same order. */ + hidden: DirectoryEntry[]; + /** Hidden ones a message brought back. Their stored entries can go. */ + returned: DirectoryEntry[]; +} + +/** + * Splits what the directory holds into the two lists. Pure, so the ones that + * came back are forgotten in an effect rather than during a render. + */ +export function splitHidden( + entries: DirectoryEntry[], + hiddenAtFor: (host: string) => HiddenAt, + now: number = Date.now(), +): SplitConversations { + const listed: DirectoryEntry[] = []; + const hidden: DirectoryEntry[] = []; + const returned: DirectoryEntry[] = []; + + for (const entry of entries) { + const at = hiddenAtFor(entry.host)[entry.conversation.conversation_id]; + if (at === undefined) { + listed.push(entry); + continue; + } + if (isBackFromHiding(at, lastMessageAt(entry.conversation), now)) { + listed.push(entry); + returned.push(entry); + continue; + } + hidden.push(entry); + } + + return { listed, hidden, returned }; +} + +function lastMessageAt(conversation: DirectConversation): string | null { + return (conversation as { last_message_at?: string | null }).last_message_at ?? null; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ReadonlyMap { + return byAccount; +} + +/** + * What each account has hidden, as a lookup that changes identity whenever + * something is hidden or put back, so a list drawn from it redraws. + */ +export function useHiddenConversations(): (host: string, serverUserId: string) => HiddenAt { + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return useMemo( + () => (host: string, serverUserId: string) => + snapshot.get(accountKey(host, serverUserId)) ?? load(host, serverUserId), + [snapshot], + ); +} + +/** For a test, so one case cannot leak into the next. */ +export function resetHiddenConversations(): void { + byAccount = new Map(); + emitChange(); +} + +/* Whether the Hidden group is open. One answer for the device rather than one + per server: it is a row in one list that spans every server. */ +let expanded = readExpanded(); +const expandedListeners = new Set<() => void>(); + +function readExpanded(): boolean { + try { + return localStorage.getItem(EXPANDED_KEY) === "1"; + } catch { + return false; + } +} + +export function setHiddenExpanded(open: boolean): void { + if (expanded === open) return; + expanded = open; + try { + localStorage.setItem(EXPANDED_KEY, open ? "1" : "0"); + } catch { + // Same as above: it holds for this session and forgets on the next launch. + } + for (const listener of expandedListeners) listener(); +} + +export function useHiddenExpanded(): boolean { + return useSyncExternalStore( + (listener) => { + expandedListeners.add(listener); + return () => { + expandedListeners.delete(listener); + }; + }, + () => expanded, + () => expanded, + ); +} diff --git a/src/packages/socket/src/hooks/useDirectMessages.ts b/src/packages/socket/src/hooks/useDirectMessages.ts index 48ddfe10..2c9b8ca0 100644 --- a/src/packages/socket/src/hooks/useDirectMessages.ts +++ b/src/packages/socket/src/hooks/useDirectMessages.ts @@ -34,11 +34,6 @@ interface UseDirectMessagesResult { groups: DirectConversation[]; /** Open one, or bring the existing one forward. Resolves when the server answers. */ openDm: (targetServerUserId: string) => void; - /** - * Take a conversation out of your own list, or put it back. Yours alone, and a - * message arriving brings it back — this tidies a sidebar, nothing more. - */ - setHidden: (conversationId: string, hidden: boolean) => void; /** * Start a group with these people, optionally named. Never converts a * one-to-one: what two people said must not become readable by a third. @@ -89,15 +84,6 @@ export function useDirectMessages({ }); }; - /* The server's answer, which is also what another device hears. Dropping the - row on the click would leave it on the phone until a refresh. */ - const onHidden = (payload: { conversation_id?: string; hidden?: boolean }) => { - if (!payload?.conversation_id || payload.hidden !== true) return; - setConversations((prev) => - prev.filter((c) => c.conversation_id !== payload.conversation_id), - ); - }; - /* Left for good, so it goes without waiting for a fresh list. */ const onLeft = (payload: { conversation_id?: string }) => { if (!payload?.conversation_id) return; @@ -136,7 +122,6 @@ export function useDirectMessages({ socket.on("dm:list", onList); socket.on("dm:opened", onOpened); socket.on("chat:new", onMessage); - socket.on("dm:hidden", onHidden); socket.on("dm:left", onLeft); socket.on("dm:error", onError); socket.emit("dm:list", { accessToken }); @@ -145,7 +130,6 @@ export function useDirectMessages({ socket.off("dm:list", onList); socket.off("dm:opened", onOpened); socket.off("chat:new", onMessage); - socket.off("dm:hidden", onHidden); socket.off("dm:left", onLeft); socket.off("dm:error", onError); }; @@ -166,14 +150,6 @@ export function useDirectMessages({ [socket, accessToken], ); - const setHidden = useCallback( - (conversationId: string, hidden: boolean) => { - if (!socket || !accessToken) return; - socket.emit("dm:setHidden", { accessToken, conversationId, hidden }); - }, - [socket, accessToken], - ); - const emit = useCallback( (event: string, payload: Record) => { if (!socket || !accessToken) return; @@ -213,7 +189,6 @@ export function useDirectMessages({ directMessages, groups, openDm, - setHidden, createGroup, updateGroup, addToGroup,