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 eslint.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export default tseslint.config(
'src/socket/handlers/presenceRateLimit.test.ts',
'src/socket/handlers/presenceRichActivity.test.ts',
'src/socket/handlers/pushAudience.test.ts',
'src/socket/handlers/readMarkers.test.ts',
'src/socket/handlers/scopeTemplates.test.ts',
'src/socket/handlers/sendAcrossRestart.test.ts',
'src/socket/handlers/sessions.test.ts',
Expand Down
10 changes: 10 additions & 0 deletions src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,16 @@ function createSchema(d: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_mentions_unseen
ON mentions(server_user_id, created_at) WHERE seen_at IS NULL;

-- Position is the message's own (created_at, id) pair, never when anybody looked. No foreign
-- key: deleting the message somebody stopped at must not forget where they were.
CREATE TABLE IF NOT EXISTS read_markers (
server_user_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
read_through_at TEXT NOT NULL,
read_through_message_id TEXT NOT NULL,
PRIMARY KEY (server_user_id, conversation_id)
);

-- A report about a person rather than about one message.
--
-- Its own table rather than a nullable message_id on reports, because the
Expand Down
1 change: 1 addition & 0 deletions src/db/sqlite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export * from "./contactPrefs";
export * from "./pushDevices";
export * from "./friends";
export * from "./mentions";
export * from "./readMarkers";
export * from "./webhooks";
export * from "./mls";
export * from "./discordImports";
36 changes: 35 additions & 1 deletion src/db/sqlite/mergeGuest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { after, before, describe, it } from "node:test";

import { eitherHasBlocked, blockUser, listBlocks } from "./blocks";
import { getSqliteDb, initSqlite } from "./connection";
import { listReadMarkers, setReadMarker } from "./readMarkers";
import { createGroupConversation, listConversationMemberIds, openDirectConversation } from "./conversations";
import { recordMentions } from "./mentions";
import { AUTHOR_COLUMNS, mergeGuestIntoAccount } from "./mergeGuest";
Expand Down Expand Up @@ -174,6 +175,39 @@ describe("merging a guest into an account that is already a member", () => {
assert.deepEqual(mentions.map((m) => m.server_user_id), [account.server_user_id]);
});

it("keeps the further read marker where both had one", async () => {
const { guest, account, guestId, accountId } = await pair("marker");
/* created_at given rather than taken from the clock: which marker is further is the
whole question, and two sends in one millisecond would decide it by id instead. */
const at = (conversation: string, iso: string) =>
insertMessage({
conversation_id: conversation, sender_server_id: "someone-else",
text: iso, attachments: null, reactions: null, created_at: new Date(iso),
});

const early = await at("general", "2026-03-01T00:00:00.000Z");
const late = await at("general", "2026-03-01T00:00:10.000Z");
const guestOnly = await at("mk-guest-only", "2026-03-01T00:00:00.000Z");
const behind = await at("forum", "2026-03-01T00:00:00.000Z");
const ahead = await at("forum", "2026-03-01T00:00:10.000Z");

await setReadMarker({ serverUserId: account.server_user_id, conversationId: "general", messageId: early.message_id });
await setReadMarker({ serverUserId: guest.server_user_id, conversationId: "general", messageId: late.message_id });
await setReadMarker({ serverUserId: guest.server_user_id, conversationId: "mk-guest-only", messageId: guestOnly.message_id });
await setReadMarker({ serverUserId: guest.server_user_id, conversationId: "forum", messageId: behind.message_id });
await setReadMarker({ serverUserId: account.server_user_id, conversationId: "forum", messageId: ahead.message_id });

mergeGuestIntoAccount(guestId, accountId);

const kept = new Map(
(await listReadMarkers(account.server_user_id)).map((m) => [m.conversation_id, m.read_through_message_id]),
);
assert.equal(kept.get("general"), late.message_id, "the guest had read further and the merge lost it");
assert.equal(kept.get("forum"), ahead.message_id, "the account had read further and the guest overwrote it");
assert.equal(kept.get("mk-guest-only"), guestOnly.message_id, "a marker only the guest held did not move");
assert.deepEqual(await listReadMarkers(guest.server_user_id), [], "the guest kept markers after the merge");
});

it("carries blocks both ways, revokes the guest's sessions and keeps the stricter mute", async () => {
const { guest, guestId, accountId } = await pair("safety");
await upsertUser("account-safety-pest", "Pest");
Expand Down Expand Up @@ -215,7 +249,7 @@ describe("merging a guest into an account that is already a member", () => {
// Keyed on the pair or on the row itself, and handled one by one in the merge.
const handledApart = new Set([
"users.server_user_id", "roles.server_user_id", "conversation_members.server_user_id",
"mentions.server_user_id", "refresh_tokens.server_user_id",
"mentions.server_user_id", "read_markers.server_user_id", "refresh_tokens.server_user_id",
"mls_devices.server_user_id", "mls_key_packages.server_user_id", "mls_welcomes.server_user_id",
"mls_removed_devices.server_user_id", "push_devices.server_user_id",
]);
Expand Down
20 changes: 20 additions & 0 deletions src/db/sqlite/mergeGuest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ function mergeInTransaction(
).run(from, to);
db.prepare(`UPDATE mentions SET server_user_id = ? WHERE server_user_id = ?`).run(to, from);

/* Read markers keep the further of the two rather than the account's, which the two
above do: merging two of your own identities should not lose read progress. */
db.prepare(
`DELETE FROM read_markers WHERE server_user_id = ?
AND conversation_id IN (
SELECT g.conversation_id FROM read_markers g
JOIN read_markers a
ON a.server_user_id = ? AND a.conversation_id = g.conversation_id
WHERE g.server_user_id = ?
AND (a.read_through_at > g.read_through_at
OR (a.read_through_at = g.read_through_at
AND a.read_through_message_id >= g.read_through_message_id)))`,
).run(from, to, from);
// Whatever the guest still holds is further along, so the account's row gives way.
db.prepare(
`DELETE FROM read_markers WHERE server_user_id = ?
AND conversation_id IN (SELECT conversation_id FROM read_markers WHERE server_user_id = ?)`,
).run(to, from);
db.prepare(`UPDATE read_markers SET server_user_id = ? WHERE server_user_id = ?`).run(to, from);

for (const [table, column] of AUTHOR_COLUMNS) {
db.prepare(`UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`).run(to, from);
}
Expand Down
171 changes: 171 additions & 0 deletions src/db/sqlite/readMarkers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";

import { initSqlite, getSqliteDb } from "./connection";
import { insertMessage } from "./messages";
import {
countUnreadByConversation,
deleteReadMarkers,
listReadMarkers,
setReadMarker,
} from "./readMarkers";

/** A real database, like card.test.ts: the counting is a join and a string compare,
and neither is worth faking. */
let dir: string;

const ALICE = "alice";
const BOB = "bob";

before(async () => {
dir = mkdtempSync(join(tmpdir(), "gryt-read-"));
process.env.DATA_DIR = dir;
await initSqlite();
});

after(() => {
delete process.env.DATA_DIR;
/* The connection stays open, there being no close for it, and Windows will not unlink
an open file. Every db test here fails its cleanup that way; the OS sweeps temp. */
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// Failing a suite on its own cleanup hides the result it just produced.
}
});

/** created_at is given, so the order is the test's rather than the clock's. */
async function say(conversation: string, sender: string, at: string, extra: { thread_id?: string } = {}) {
const stored = await insertMessage({
conversation_id: conversation,
sender_server_id: sender,
text: `from ${sender} at ${at}`,
attachments: null,
reactions: null,
created_at: new Date(at),
...extra,
});
return stored.message_id;
}

describe("read markers", () => {
it("counts nothing for a conversation nobody has opened", async () => {
await say("c-untouched", BOB, "2026-01-01T00:00:00.000Z");
const counts = await countUnreadByConversation(ALICE);
assert.equal(counts["c-untouched"], undefined, "a channel never opened is lighting up");
assert.deepEqual(await listReadMarkers(ALICE), []);
});

it("reads everything when no message is named, as Mark as read does", async () => {
await say("c-all", BOB, "2026-01-03T00:00:00.000Z");
await say("c-all", BOB, "2026-01-03T00:00:01.000Z");
assert.equal(await setReadMarker({ serverUserId: ALICE, conversationId: "c-all" }), true);
assert.equal((await countUnreadByConversation(ALICE))["c-all"], undefined);
assert.equal(await setReadMarker({ serverUserId: ALICE, conversationId: "c-empty" }), false);
});

it("counts what arrived after the marker, and not the marker itself", async () => {
const first = await say("c-basic", BOB, "2026-01-02T00:00:00.000Z");
await say("c-basic", BOB, "2026-01-02T00:00:01.000Z");
await say("c-basic", BOB, "2026-01-02T00:00:02.000Z");

assert.equal(await setReadMarker({ serverUserId: ALICE, conversationId: "c-basic", messageId: first }), true);
const counts = await countUnreadByConversation(ALICE);
assert.equal(counts["c-basic"], 2);
});

it("does not count your own messages", async () => {
const first = await say("c-mine", BOB, "2026-01-03T00:00:00.000Z");
await setReadMarker({ serverUserId: ALICE, conversationId: "c-mine", messageId: first });
await say("c-mine", ALICE, "2026-01-03T00:00:01.000Z");
assert.equal((await countUnreadByConversation(ALICE))["c-mine"], undefined);

await say("c-mine", BOB, "2026-01-03T00:00:02.000Z");
assert.equal((await countUnreadByConversation(ALICE))["c-mine"], 1, "somebody else's message is not counted");
});

it("leaves thread replies to the thread's own count", async () => {
const root = await say("c-thread", BOB, "2026-01-04T00:00:00.000Z");
await setReadMarker({ serverUserId: ALICE, conversationId: "c-thread", messageId: root });
await say("c-thread", BOB, "2026-01-04T00:00:01.000Z", { thread_id: "t-1" });
assert.equal((await countUnreadByConversation(ALICE))["c-thread"], undefined);
});

it("only ever moves forwards", async () => {
const a = await say("c-back", BOB, "2026-01-05T00:00:00.000Z");
const b = await say("c-back", BOB, "2026-01-05T00:00:01.000Z");

await setReadMarker({ serverUserId: ALICE, conversationId: "c-back", messageId: b });
assert.equal((await countUnreadByConversation(ALICE))["c-back"], undefined);

/* The second device finished last but had read less. Without the guard this would
reopen a conversation somebody had already cleared. */
const moved = await setReadMarker({ serverUserId: ALICE, conversationId: "c-back", messageId: a });
assert.equal(moved, false, "the marker went backwards");
assert.equal((await countUnreadByConversation(ALICE))["c-back"], undefined);
});

it("breaks a same-millisecond tie on the message id", async () => {
const same = "2026-01-06T00:00:00.000Z";
const ids = [await say("c-tie", BOB, same), await say("c-tie", BOB, same), await say("c-tie", BOB, same)];
const sorted = [...ids].sort();

await setReadMarker({ serverUserId: ALICE, conversationId: "c-tie", messageId: sorted[0] });
/* Ids are random UUIDs, so this is the only ordering the pair has. Comparing on
created_at alone would count all three or none. */
assert.equal((await countUnreadByConversation(ALICE))["c-tie"], 2);

await setReadMarker({ serverUserId: ALICE, conversationId: "c-tie", messageId: sorted[2] });
assert.equal((await countUnreadByConversation(ALICE))["c-tie"], undefined);
});

it("moves nothing for a message this server does not have", async () => {
const real = await say("c-ghost", BOB, "2026-01-07T00:00:00.000Z");
await setReadMarker({ serverUserId: ALICE, conversationId: "c-ghost", messageId: real });
await say("c-ghost", BOB, "2026-01-07T00:00:01.000Z");

assert.equal(
await setReadMarker({ serverUserId: ALICE, conversationId: "c-ghost", messageId: "not-a-message" }),
false,
);
assert.equal((await countUnreadByConversation(ALICE))["c-ghost"], 1, "an unknown id disturbed the marker");
});

it("keeps the marker when the message it points at is deleted", async () => {
const first = await say("c-gone", BOB, "2026-01-08T00:00:00.000Z");
await say("c-gone", BOB, "2026-01-08T00:00:01.000Z");
await setReadMarker({ serverUserId: ALICE, conversationId: "c-gone", messageId: first });

getSqliteDb().prepare(`DELETE FROM messages WHERE conversation_id = ? AND message_id = ?`).run("c-gone", first);

/* No foreign key here, deliberately: deleting the message somebody stopped at must
not forget where they were. With a cascade this would read as caught up. */
assert.equal((await listReadMarkers(ALICE)).some((m) => m.conversation_id === "c-gone"), true);
assert.equal((await countUnreadByConversation(ALICE))["c-gone"], 1);
});

it("stores the message's own time, not the time of the read", async () => {
const at = "2026-01-09T12:34:56.000Z";
const id = await say("c-when", BOB, at);
await setReadMarker({ serverUserId: ALICE, conversationId: "c-when", messageId: id });

const marker = (await listReadMarkers(ALICE)).find((m) => m.conversation_id === "c-when");
assert.equal(marker?.read_through_at, at, "the marker is not the message's timestamp");
assert.equal(marker?.read_through_message_id, id);
});

it("keeps people apart, and forgets one on request", async () => {
const id = await say("c-two", BOB, "2026-01-10T00:00:00.000Z");
await say("c-two", BOB, "2026-01-10T00:00:01.000Z");
await setReadMarker({ serverUserId: ALICE, conversationId: "c-two", messageId: id });

assert.equal((await listReadMarkers(BOB)).some((m) => m.conversation_id === "c-two"), false);

assert.equal(await deleteReadMarkers(ALICE, "c-two"), 1);
assert.equal((await countUnreadByConversation(ALICE))["c-two"], undefined);
assert.ok((await listReadMarkers(ALICE)).length > 0, "every marker went, not just the one asked for");
});
});
Loading
Loading