From 21966c9eca1aac130b86cb99d16c350f813fbf2b Mon Sep 17 00:00:00 2001 From: Sivert Date: Tue, 15 Sep 2026 22:12:30 +0200 Subject: [PATCH] The guest prompt's yes said nothing, and no kept the guest session (GRYT-1225) Yes now shows what the server did, from identityClaim on server:joined, and only for the join right after the click. chat:merge_user rewrites the sender and reactions on messages the client holds, in the channel and an open thread. No drops this device's session for that server and rejoins as the account. The guest's keys and visit history stay on the device, and the guest stays as it was on the server. Ask me later is unchanged. A join bumps tokenRevision now, as a refresh does. Hooks reading the access token from useServerState kept the guest's old one after either answer. After a merge, the membership_required that token got back wiped the new session. A second tab that gets token:revoked with identity_merged drops its refresh token too and rejoins. That token names a membership that's gone. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 1 + package.json | 1 + scripts/check-identity-claim.mjs | 89 +++++++++++++++++++ src/hooks/useIdentityClaim.ts | 32 ++++--- src/packages/lib/identityClaimOutcome.ts | 42 +++++++++ .../src/hooks/registerServerSocketEvents.ts | 8 +- src/packages/socket/src/hooks/useChat.ts | 21 +++++ .../socket/src/hooks/useSocketEvents.ts | 7 ++ src/packages/socket/src/hooks/useThreads.ts | 15 ++++ src/packages/socket/src/utils/mergeSender.ts | 33 +++++++ 10 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 scripts/check-identity-claim.mjs create mode 100644 src/packages/lib/identityClaimOutcome.ts create mode 100644 src/packages/socket/src/utils/mergeSender.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25106623..6e1f603e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: - run: yarn test:message-key-offer - run: yarn test:reset-warning - run: yarn test:guest-history + - run: yarn test:identity-claim # Fires once per version, so a mistake is a modal nobody sees again or one # that greets a fresh install. Neither shows up in a build. diff --git a/package.json b/package.json index 17638900..68fd74e1 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:message-key-offer": "node scripts/check-message-key-offer.mjs", "test:reset-warning": "node scripts/check-reset-warning.mjs", "test:guest-history": "node scripts/check-guest-history.mjs", + "test:identity-claim": "node scripts/check-identity-claim.mjs", "test:whats-new": "node --disable-warning=ExperimentalWarning scripts/check-whats-new.mjs", "test:whats-new-since": "node scripts/check-whats-new-since.mjs", "test:whats-new-reachable": "node scripts/check-whats-new-reachable.mjs", diff --git a/scripts/check-identity-claim.mjs b/scripts/check-identity-claim.mjs new file mode 100644 index 00000000..56ef3dae --- /dev/null +++ b/scripts/check-identity-claim.mjs @@ -0,0 +1,89 @@ +/* eslint-env node */ + +/** + * What a yes to "Were you here as a guest before?" shows, and how held messages + * follow a guest the server merged into an account. + */ + +import assert from "node:assert/strict"; + +const { claimOutcomeToast, expectClaimOutcome, takeClaimOutcome, CLAIM_OUTCOME_WAIT_MS } = await import( + "../src/packages/lib/identityClaimOutcome.ts" +); +const { mergeSender, mergeSenders } = await import("../src/packages/socket/src/utils/mergeSender.ts"); + +// ── the toast for each answer the server can give ─────────────────── + +assert.equal(claimOutcomeToast("carried")?.tone, "success"); +assert.equal(claimOutcomeToast("merged")?.tone, "success"); +assert.match(claimOutcomeToast("merged")?.message ?? "", /keeps its name, picture and roles/); +assert.equal(claimOutcomeToast("no_prior_membership")?.tone, "info"); +assert.equal(claimOutcomeToast("failed")?.tone, "error"); + +// A server from before the field says nothing, and neither does the client. +assert.equal(claimOutcomeToast(undefined), null); +assert.equal(claimOutcomeToast("account_already_member"), null); + +// ── only the join right after the click gets one ──────────────────── + +assert.equal(takeClaimOutcome("a.example", 0), false, "nobody asked"); + +expectClaimOutcome("a.example", 1_000); +assert.equal(takeClaimOutcome("b.example", 1_500), false, "another server's join is not the answer"); +assert.equal(takeClaimOutcome("a.example", 1_500), true); +assert.equal(takeClaimOutcome("a.example", 1_600), false, "a rejoin afterwards reports the stored yes again"); + +expectClaimOutcome("a.example", 0); +assert.equal(takeClaimOutcome("a.example", CLAIM_OUTCOME_WAIT_MS + 1), false, "a join that came much later"); + +// ── held messages follow the merge ────────────────────────────────── + +const guest = "user_guest"; +const account = "user_account"; +const other = "user_other"; + +const message = (id, sender, reactions = null) => ({ + conversation_id: "general", + message_id: id, + sender_server_id: sender, + text: id, + attachments: null, + created_at: "2026-09-15T10:00:00.000Z", + reactions, +}); + +{ + const sent = mergeSender(message("m1", guest), guest, account); + assert.equal(sent.sender_server_id, account); +} + +{ + const both = mergeSender( + message("m2", other, [ + { src: "👍", amount: 3, users: [guest, other, account] }, + { src: "🎉", amount: 1, users: [guest] }, + { src: "👀", amount: 1, users: [other] }, + ]), + guest, + account, + ); + assert.deepEqual(both.reactions?.[0], { src: "👍", amount: 2, users: [account, other] }, "somebody who reacted as both counts once"); + assert.deepEqual(both.reactions?.[1], { src: "🎉", amount: 1, users: [account] }); + assert.equal(both.sender_server_id, other); +} + +{ + const untouched = message("m3", other, [{ src: "👀", amount: 1, users: [other] }]); + assert.equal(mergeSender(untouched, guest, account), untouched, "a row that never named the guest keeps its identity"); + + const list = [untouched, message("m4", other)]; + assert.equal(mergeSenders(list, guest, account), list, "so does a list with nothing to change"); + + const mixed = [untouched, message("m5", guest)]; + const merged = mergeSenders(mixed, guest, account); + assert.notEqual(merged, mixed); + assert.equal(merged[0], untouched); + assert.equal(merged[1].sender_server_id, account); +} + +console.log("identity claim: outcome toasts and merged senders behave"); diff --git a/src/hooks/useIdentityClaim.ts b/src/hooks/useIdentityClaim.ts index 03a0bb24..eec31c74 100644 --- a/src/hooks/useIdentityClaim.ts +++ b/src/hooks/useIdentityClaim.ts @@ -8,6 +8,7 @@ import { setClaimDecision, useAccount, } from "@/common"; +import { expectClaimOutcome } from "@/lib/identityClaimOutcome"; import { useSockets } from "@/socket"; /** @@ -28,13 +29,10 @@ export function useIdentityClaim() { [isSignedIn], ); - /** - * Agree to it, and make it happen. Dropping the session puts the next connect - * back on the join path, where the link is signed (GRYT-286). - */ - const claim = useCallback( + /** Without a stored session the next connect takes the join path, which answers + as the account and signs the link only after a yes. */ + const rejoinAsAccount = useCallback( (host: string) => { - setClaimDecision(identityScopeFor(host), "yes"); removeServerAccessToken(host); removeServerRefreshToken(host); reconnectServer(host); @@ -42,10 +40,24 @@ export function useIdentityClaim() { [reconnectServer], ); - /** Decline, and stop being asked about this server. */ - const decline = useCallback((host: string) => { - setClaimDecision(identityScopeFor(host), "no"); - }, []); + const claim = useCallback( + (host: string) => { + setClaimDecision(identityScopeFor(host), "yes"); + expectClaimOutcome(host); + rejoinAsAccount(host); + }, + [rejoinAsAccount], + ); + + /** The guest stays as it is on the server and this device stops using it there. + Its keys are kept, so the server menu can still move it later. */ + const decline = useCallback( + (host: string) => { + setClaimDecision(identityScopeFor(host), "no"); + rejoinAsAccount(host); + }, + [rejoinAsAccount], + ); return { canClaim, claim, decline }; } diff --git a/src/packages/lib/identityClaimOutcome.ts b/src/packages/lib/identityClaimOutcome.ts new file mode 100644 index 00000000..8a624d87 --- /dev/null +++ b/src/packages/lib/identityClaimOutcome.ts @@ -0,0 +1,42 @@ +export interface ClaimOutcomeToast { + tone: "success" | "info" | "error"; + message: string; +} + +/** What the server did with a yes, from `identityClaim` on `server:joined`. Null + for a server too old to say, which then says nothing. */ +export function claimOutcomeToast(outcome: unknown): ClaimOutcomeToast | null { + switch (outcome) { + case "carried": + return { tone: "success", message: "Moved the guest to your account." }; + case "merged": + return { + tone: "success", + message: "Moved your guest messages to your account. Your account keeps its name, picture and roles.", + }; + case "no_prior_membership": + return { tone: "info", message: "This server doesn't have a guest from this device, so nothing moved." }; + case "failed": + return { tone: "error", message: "The server couldn't move the guest to your account." }; + default: + return null; + } +} + +/** A join answers well inside this. Past it the join was refused, and a later one + must not be read as the answer to this yes. */ +export const CLAIM_OUTCOME_WAIT_MS = 20_000; + +const awaiting = new Map(); + +/** Every rejoin reports the stored yes again, so only the one right after the + click is worth a toast. */ +export function expectClaimOutcome(host: string, now = Date.now()): void { + awaiting.set(host, now + CLAIM_OUTCOME_WAIT_MS); +} + +export function takeClaimOutcome(host: string, now = Date.now()): boolean { + const until = awaiting.get(host); + awaiting.delete(host); + return until !== undefined && now <= until; +} diff --git a/src/packages/socket/src/hooks/registerServerSocketEvents.ts b/src/packages/socket/src/hooks/registerServerSocketEvents.ts index aff60ed3..b8fad4ae 100644 --- a/src/packages/socket/src/hooks/registerServerSocketEvents.ts +++ b/src/packages/socket/src/hooks/registerServerSocketEvents.ts @@ -30,6 +30,7 @@ import { ownDmPublicKey, readSealedVault, } from "@/common"; +import { claimOutcomeToast, takeClaimOutcome } from "@/lib/identityClaimOutcome"; import { Server, serverDetails, @@ -286,7 +287,7 @@ export function registerServerSocketEvents(socket: Socket, host: string, ctx: Se }); }); - socket.on("server:joined", (joinInfo: { accessToken: string; fileToken?: string; refreshToken?: string; nickname: string; avatarFileId?: string | null; avatarWorn?: string | null }) => { + socket.on("server:joined", (joinInfo: { accessToken: string; fileToken?: string; refreshToken?: string; nickname: string; avatarFileId?: string | null; avatarWorn?: string | null; identityClaim?: unknown }) => { setServerAccessToken(host, joinInfo.accessToken); // Before anything renders. Every avatar and every picture reaches for this, // so storing it late means a screen of broken images on the first join. @@ -299,6 +300,11 @@ export function registerServerSocketEvents(socket: Socket, host: string, ctx: Se setServerRefreshToken(host, joinInfo.refreshToken); } + const claimed = takeClaimOutcome(host) ? claimOutcomeToast(joinInfo.identityClaim) : null; + if (claimed?.tone === "success") toast.success(claimed.message, { duration: 6000 }); + else if (claimed?.tone === "error") toast.error(claimed.message, { duration: 8000 }); + else if (claimed) toast(claimed.message, { duration: 6000, icon: createElement(PiInfoFill, { size: 18 }) }); + // Said out loud, because the request was made in a dialog that has long since // closed and grey going to normal is a change nobody is watching for. if (serversRef.current[host]?.approvalRequestedAt) { diff --git a/src/packages/socket/src/hooks/useChat.ts b/src/packages/socket/src/hooks/useChat.ts index 6b5b165d..3ee44fb9 100644 --- a/src/packages/socket/src/hooks/useChat.ts +++ b/src/packages/socket/src/hooks/useChat.ts @@ -12,6 +12,7 @@ import { type ForumTag,serverDetailsList as ServerDetailsList } from "@/settings import { PiInfoFill } from "../../../../lib/icons"; import type { ChatMessage } from "../components/chatUtils"; +import { mergeSenders } from "../utils/mergeSender"; import { openSealedAttachment } from "../utils/sealedAttachments"; import { sealedNotificationBody } from "../utils/sealedNotification"; import { @@ -477,6 +478,24 @@ export function useChat({ }); }; + const onMergeUser = (payload: { from_server_user_id?: string; to_server_user_id?: string }) => { + const from = payload?.from_server_user_id; + const to = payload?.to_server_user_id; + if (!from || !to) return; + setChatMessages((prev) => mergeSenders(prev, from, to)); + setMessageCache((prev) => { + let changed = false; + const next = { ...prev }; + for (const key of Object.keys(next)) { + const merged = mergeSenders(next[key], from, to); + if (merged === next[key]) continue; + next[key] = merged; + changed = true; + } + return changed ? next : prev; + }); + }; + // socket.io reuses the same Socket, so neither effect re-runs on a reconnect. // Clearing the guard and bumping the nonce sends the fetch round again. const onConnect = () => { @@ -493,6 +512,7 @@ export function useChat({ currentConnection.on("report:submitted", onReportSubmitted); currentConnection.on("report:already_reported", onAlreadyReported); currentConnection.on("chat:purge_user", onPurgeUser); + currentConnection.on("chat:merge_user", onMergeUser); return () => { currentConnection.off("connect", onConnect); currentConnection.off("chat:new", onNew); @@ -503,6 +523,7 @@ export function useChat({ currentConnection.off("report:submitted", onReportSubmitted); currentConnection.off("report:already_reported", onAlreadyReported); currentConnection.off("chat:purge_user", onPurgeUser); + currentConnection.off("chat:merge_user", onMergeUser); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentConnection, activeConversationId, cacheKeyFor]); diff --git a/src/packages/socket/src/hooks/useSocketEvents.ts b/src/packages/socket/src/hooks/useSocketEvents.ts index 7a1d0fd6..969a3cc8 100644 --- a/src/packages/socket/src/hooks/useSocketEvents.ts +++ b/src/packages/socket/src/hooks/useSocketEvents.ts @@ -319,6 +319,9 @@ export function useSocketEvents(sockets: Sockets, deps: SocketEventDeps) { socket.on("token:revoked", (info: { reason?: string; message?: string }) => { removeServerAccessToken(host); + // Another tab moved this guest into the account. Its refresh token names a + // membership that is gone, so the retry below has to be a fresh join. + if (info?.reason === "identity_merged") removeServerRefreshToken(host); const pending = revokedTimersRef.current[host]; if (pending) { @@ -514,6 +517,10 @@ export function useSocketEvents(sockets: Sockets, deps: SocketEventDeps) { setIsServerMuted, setIsServerDeafened, }); + + // After the handler above has stored it. A join mints a new token too, and a + // hook still holding the old one acts as whoever this socket used to be. + socket.on("server:joined", () => onTokenRefreshedRef.current()); }); for (const host of registeredRef.current) { diff --git a/src/packages/socket/src/hooks/useThreads.ts b/src/packages/socket/src/hooks/useThreads.ts index 3124e573..b27e4d84 100644 --- a/src/packages/socket/src/hooks/useThreads.ts +++ b/src/packages/socket/src/hooks/useThreads.ts @@ -4,6 +4,7 @@ import toast from "react-hot-toast"; import { clearThreadMentions, getServerAccessToken, setOpenThread } from "@/common"; import type { ChatMessage } from "../components/chatUtils"; +import { mergeSender, mergeSenders } from "../utils/mergeSender"; import { uploadChatFile } from "./uploadChatFile"; /** @@ -278,6 +279,18 @@ export function useThreads( patchOpen(id, (messages) => messages.filter((m) => m.message_id !== id)); }; + const onMergeUser = (payload: { from_server_user_id?: string; to_server_user_id?: string }) => { + const from = payload?.from_server_user_id; + const to = payload?.to_server_user_id; + if (!from || !to) return; + setOpen((o) => { + if (!o) return o; + const root = o.root ? mergeSender(o.root, from, to) : o.root; + const messages = mergeSenders(o.messages, from, to); + return root === o.root && messages === o.messages ? o : { ...o, root, messages }; + }); + }; + const onError = (e: { message?: string } | string) => { const message = typeof e === "string" ? e : e?.message; if (message) toast.error(message); @@ -308,6 +321,7 @@ export function useThreads( socket.on("chat:reaction", onReaction as (p: never) => void); socket.on("chat:edited", onEdited as (p: never) => void); socket.on("chat:deleted", onMessageDeleted as (p: never) => void); + socket.on("chat:merge_user", onMergeUser as (p: never) => void); return () => { socket.off("thread:created", onCreated as (p: never) => void); socket.off("thread:updated", onUpdated as (p: never) => void); @@ -319,6 +333,7 @@ export function useThreads( socket.off("chat:reaction", onReaction as (p: never) => void); socket.off("chat:edited", onEdited as (p: never) => void); socket.off("chat:deleted", onMessageDeleted as (p: never) => void); + socket.off("chat:merge_user", onMergeUser as (p: never) => void); }; }, [socketConnection, conversationId, serverHost]); diff --git a/src/packages/socket/src/utils/mergeSender.ts b/src/packages/socket/src/utils/mergeSender.ts new file mode 100644 index 00000000..48e73340 --- /dev/null +++ b/src/packages/socket/src/utils/mergeSender.ts @@ -0,0 +1,33 @@ +import type { ChatMessage } from "../components/chatUtils"; + +/** A guest folded into an account on the server. A row that never named the guest + comes back as the same object, so nothing re-renders for it. */ +export function mergeSender(message: ChatMessage, from: string, to: string): ChatMessage { + const sent = message.sender_server_id === from; + const reacted = message.reactions?.some((r) => r.users?.includes(from)) ?? false; + if (!sent && !reacted) return message; + + return { + ...message, + sender_server_id: sent ? to : message.sender_server_id, + reactions: + reacted && message.reactions + ? message.reactions.map((r) => { + if (!r.users?.includes(from)) return r; + // Somebody who reacted as both the guest and the account counts once. + const users = [...new Set(r.users.map((u) => (u === from ? to : u)))]; + return { ...r, users, amount: users.length }; + }) + : message.reactions, + }; +} + +export function mergeSenders(messages: ChatMessage[], from: string, to: string): ChatMessage[] { + let changed = false; + const next = messages.map((m) => { + const merged = mergeSender(m, from, to); + if (merged !== m) changed = true; + return merged; + }); + return changed ? next : messages; +}