Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions scripts/check-identity-claim.mjs
Original file line number Diff line number Diff line change
@@ -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");
32 changes: 22 additions & 10 deletions src/hooks/useIdentityClaim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
setClaimDecision,
useAccount,
} from "@/common";
import { expectClaimOutcome } from "@/lib/identityClaimOutcome";
import { useSockets } from "@/socket";

/**
Expand All @@ -28,24 +29,35 @@ 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);
},
[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 };
}
42 changes: 42 additions & 0 deletions src/packages/lib/identityClaimOutcome.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

/** 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;
}
8 changes: 7 additions & 1 deletion src/packages/socket/src/hooks/registerServerSocketEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ownDmPublicKey,
readSealedVault,
} from "@/common";
import { claimOutcomeToast, takeClaimOutcome } from "@/lib/identityClaimOutcome";
import {
Server,
serverDetails,
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions src/packages/socket/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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);
Expand All @@ -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]);
Expand Down
7 changes: 7 additions & 0 deletions src/packages/socket/src/hooks/useSocketEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions src/packages/socket/src/hooks/useThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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]);

Expand Down
33 changes: 33 additions & 0 deletions src/packages/socket/src/utils/mergeSender.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading