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; +}