Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 fee3fbb7fc fix(threads): viewing a room no longer reads threads you don't follow (#314)
CI / Build & Quality Checks (pull_request) Successful in 1m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m31s
A passive read (just looking at the room) sent a threaded receipt for
every unread thread you don't follow, so the Threads panel's Unread
filter and Mark all read only ever worked for followed threads.
Decision (2026-10-19): leave them unread until opened, but stop
counting unfollowed threads toward the room's unread dot
(getUnreadInfo subtracts them like muted threads; a mention makes a
thread followed, so highlights are never hidden). isThreadFollowed
moves to utils/room. Adds a Playwright regression.

Fixes #314

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:52:04 -04:00
13 changed files with 149 additions and 134 deletions
+40
View File
@@ -265,6 +265,46 @@ test.describe('local homeserver regression', () => {
await expect.poll(threadUnread).toBe(false);
});
test("a thread I don't follow stays unread while I view the room (#314)", async ({ page }) => {
const room = await createRoom(alice, 'Unfollowed Thread Room', { invite: [bob.userId] });
await joinRoom(bob, room);
const inThread = (root: string) => ({
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
const root = await sendText(bob, room, "bob's thread root");
await loginUI(page, alice);
await openRoom(page, room);
await sendText(bob, room, 'reply in bob thread', inThread(root));
await sendText(bob, room, 'newer main message 314');
await expect(page.getByText('newer main message 314')).toBeVisible();
const threadUnread = async () => {
const filter = {
room: {
rooms: [room],
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
},
};
const sync = await api<{
rooms: {
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
};
}>(
'GET',
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
alice.token,
);
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
};
await page.waitForTimeout(2000); // let any receipt the room view would send go out
// Viewing the room used to read every thread you don't follow (#314).
expect(await threadUnread()).toBe(true);
});
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
const room = await createRoom(alice, 'Lightbox Room');
const png = Buffer.from(
+2 -6
View File
@@ -13,13 +13,11 @@ import {
import React, { ReactNode } from 'react';
import * as css from './styles.css';
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
import { isDndPresence } from '../../utils/busyPresence';
const PresenceToColor: Record<Presence, MainColor> = {
[Presence.Online]: 'Success',
[Presence.Unavailable]: 'Warning',
[Presence.Offline]: 'Secondary',
[Presence.Busy]: 'Critical',
};
type PresenceBadgeProps = {
@@ -33,11 +31,9 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
// Disturb" to match PresenceRingAvatar and the settings picker (which both
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
// [Gitea #283] or MSC3026 busy, which keeps a real status line.
const isDnd = isDndPresence(presence, status);
const legacyDnd = isDnd && status === 'dnd';
const isDnd = presence === Presence.Unavailable && status === 'dnd';
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
const displayStatus = legacyDnd ? undefined : status;
const displayStatus = isDnd ? undefined : status;
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
return (
@@ -4,7 +4,6 @@ import { Presence, useUserPresence } from '../../hooks/useUserPresence';
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
if (!presence || presence === Presence.Offline) return null;
if (presence === Presence.Busy) return color.Critical.Main; // [Gitea #283] DND
if (presence === Presence.Unavailable) {
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
}
@@ -38,11 +38,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import {
describePresenceError,
setPresenceWithRetry,
writePresence,
} from '../../../utils/presenceWrite';
import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
@@ -558,7 +554,7 @@ function ProfileStatus() {
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
// Preserve the user's chosen presence when clearing the status message.
writePresence(mx, {
mx.setPresence({
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: '',
}).catch(() => undefined);
+7 -31
View File
@@ -1,27 +1,20 @@
import { useEffect, useRef } from 'react';
import { SetPresence } from 'matrix-js-sdk';
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
import { useMatrixClient } from './useMatrixClient';
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import {
BUSY_PRESENCE,
BUSY_PRESENCE_FEATURE,
isBusyPresenceSupported,
setBusyPresenceSupported,
} from '../utils/busyPresence';
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const ACTIVITY_THROTTLE_MS = 1000;
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
export type PresenceState = 'online' | 'unavailable' | 'offline' | typeof BUSY_PRESENCE;
export type PresenceState = 'online' | 'unavailable' | 'offline';
/**
* Single source of truth for mapping the user's presence preference to the
* Matrix presence value: auto/online → 'online', idle → 'unavailable', dnd →
* MSC3026 busy (or 'unavailable' where the server lacks it),
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
* status writer so setting/clearing a status message never overrides the user's
* chosen presence (e.g. outing an Invisible user as online).
@@ -31,10 +24,7 @@ export function presenceStateFromSetting(
hidePresence: boolean,
): PresenceState {
if (hidePresence || presenceStatus === 'invisible') return 'offline';
// [Gitea #283] DND is MSC3026 busy where the server supports it, so status
// writes (save / clear / auto-clear) don't downgrade it to "unavailable".
if (presenceStatus === 'dnd') return isBusyPresenceSupported() ? BUSY_PRESENCE : 'unavailable';
if (presenceStatus === 'idle') return 'unavailable';
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
return 'online';
}
@@ -72,7 +62,8 @@ export function usePresenceUpdater() {
// by a heartbeat that changed nothing.
const send = (write: PresenceWrite, label: string) => {
if (!deduper.shouldSend(write)) return Promise.resolve();
return writePresence(mx, write)
return mx
.setPresence(write)
.then(() => deduper.sent(write))
.catch((err) => warnPresenceFailure(label, err));
};
@@ -143,22 +134,7 @@ export function usePresenceUpdater() {
return undefined;
}
if (presenceStatus === 'dnd') {
// [Gitea #283] Real "busy" presence where supported: the status text
// stays yours. /sync reporting "online" doesn't override busy on
// Synapse, so the sync loop can stay online. Older servers keep the
// legacy unavailable + "dnd" text encoding.
mx.doesServerSupportUnstableFeature(BUSY_PRESENCE_FEATURE)
.catch(() => false)
.then(async (busy) => {
setBusyPresenceSupported(busy);
if (!busy) {
setUnavailable('dnd');
return;
}
syncPresence(SetPresence.Online);
const status = await resolveStatus();
send({ presence: BUSY_PRESENCE, ...(status ? { status_msg: status } : {}) }, 'busy');
});
setUnavailable('dnd');
return undefined;
}
-3
View File
@@ -6,8 +6,6 @@ export enum Presence {
Online = 'online',
Unavailable = 'unavailable',
Offline = 'offline',
// [Gitea #283] MSC3026 busy: Lotus's Do Not Disturb.
Busy = 'org.matrix.msc3026.busy',
}
export type UserPresence = {
@@ -131,7 +129,6 @@ export const usePresenceLabel = (): Record<Presence, string> =>
[Presence.Online]: 'Online',
[Presence.Unavailable]: 'Idle',
[Presence.Offline]: 'Offline',
[Presence.Busy]: 'Do Not Disturb',
}),
[],
);
-11
View File
@@ -1,11 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BUSY_PRESENCE, isDndPresence } from './busyPresence';
test('isDndPresence: busy, or the legacy unavailable + "dnd" text (#283)', () => {
assert.equal(isDndPresence(BUSY_PRESENCE, '🎮 Gaming'), true);
assert.equal(isDndPresence(BUSY_PRESENCE, undefined), true);
assert.equal(isDndPresence('unavailable', 'dnd'), true);
assert.equal(isDndPresence('unavailable', 'lunch'), false);
assert.equal(isDndPresence('online', 'dnd'), false);
});
-21
View File
@@ -1,21 +0,0 @@
/**
* [Gitea #283] Do Not Disturb as a real presence state: MSC3026 "busy"
* (Synapse `experimental_features.msc3026_enabled`). Before, DND was sent as
* presence `unavailable` with the literal status text "dnd", which replaced
* your status and showed as "dnd" in other clients.
*/
export const BUSY_PRESENCE = 'org.matrix.msc3026.busy';
export const BUSY_PRESENCE_FEATURE = 'org.matrix.msc3026.busy_presence';
let busySupported = false;
/** Recorded by the presence updater from the server's unstable_features. */
export const setBusyPresenceSupported = (supported: boolean): void => {
busySupported = supported;
};
export const isBusyPresenceSupported = (): boolean => busySupported;
/** Pure: whether a presence means Do Not Disturb (busy, or the legacy "dnd" text). */
export const isDndPresence = (presence: string | undefined, status: string | undefined): boolean =>
presence === BUSY_PRESENCE || (presence === 'unavailable' && status === 'dnd');
+17 -11
View File
@@ -169,7 +169,7 @@ test('private receipt flag uses ReadPrivate', async () => {
// follows unread; an explicit mark-as-read still clears everything.
const sender = (userId: string) => ({ getSender: () => userId });
test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => {
test('passive: threads stay unread, followed or not; main receipt is scoped to main (#314)', async () => {
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
const other = thread('$other', evt('$r2'));
const { mx, calls } = setup({
@@ -181,10 +181,9 @@ test('passive: a thread I replied in stays unread; main receipt is scoped to mai
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', false], // main-scoped: unthreaded would also read $mine's reply
['$r2', false],
],
// main-scoped (unthreaded would read the threads' replies), and no thread
// receipts: [Gitea #314] unfollowed threads used to be read silently.
[['b', false]],
);
});
@@ -203,7 +202,7 @@ test('passive: threads I started or was mentioned in stay unread', async () => {
assert.equal(calls.length, 0);
});
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
test('passive: an unfollowed unread thread is left unread too (#314)', async () => {
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
@@ -214,10 +213,16 @@ test('passive: no followed thread unread → main receipt stays unthreaded', asy
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', true],
['$r2', false],
],
[['b', false]],
);
});
test('passive: no thread unread → main receipt stays unthreaded', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [] });
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[['b', true]],
);
});
@@ -249,9 +254,10 @@ test('passive: the thread open in the panel is left to the panel', async () => {
threadUnread: { $open: 1, $other: 1 },
});
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
// The open thread is left to the panel, and [#314] the other one stays unread.
assert.deepEqual(
calls.map((c) => c.eventId),
['$r2'],
[],
);
});
+12 -18
View File
@@ -1,27 +1,19 @@
import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
import { MatrixClient, NotificationCountType, ReceiptType, Thread } from 'matrix-js-sdk';
import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
import { isThreadFollowed } from './room';
/**
* [Gitea #217] Threads the user is following: they started it, replied in it,
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
* thread bundle and lags a reply we just sent, so our own loaded events count too.
*/
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
if (thread.hasCurrentUserParticipated) return true;
if (userId && thread.rootEvent?.getSender() === userId) return true;
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
return (
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
);
}
// isThreadFollowed lives in utils/room (getUnreadInfo needs it too).
export { isThreadFollowed };
export type MarkAsReadOptions = {
/**
* [Gitea #217] The read comes from simply looking at the room (timeline at
* the bottom and focused), not an explicit "mark as read". Threads the user
* follows stay unread until their panel is opened, like Slack and Discord;
* the rest are cleared so they don't keep the room dot lit forever.
* the bottom and focused), not an explicit "mark as read". Threads stay
* unread until opened (or "Mark all read"), like Slack and Discord.
* [Gitea #314] That includes threads you don't follow; those no longer
* count toward the room's unread dot (see getUnreadInfo), so leaving them
* unread doesn't keep the room lit.
*/
passive?: boolean;
/**
@@ -66,7 +58,9 @@ export async function markAsRead(
const threads = room.getThreads();
const threadUnread = (thread: Thread) =>
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId());
// [Gitea #314] A passive read leaves every thread's state alone (it used to
// silently read every thread you don't follow).
const keepThread = (_thread: Thread) => passive;
// [Gitea #217] An unthreaded receipt also reads every thread reply older than
// it, so a passive read while a followed thread is unread must stay scoped
// to the main timeline or the next main message would clear that thread.
+3 -26
View File
@@ -1,5 +1,4 @@
import { MatrixClient, MatrixError, IPresenceOpts, Method } from 'matrix-js-sdk';
import { BUSY_PRESENCE } from './busyPresence';
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
/**
* Synapse rate-limits `PUT /presence/{user}/status` to ONE request per 10 s per
@@ -11,29 +10,7 @@ import { BUSY_PRESENCE } from './busyPresence';
* and `PresenceDeduper` lets the heartbeat skip writes that would not change
* anything, so it stops spending the budget in the first place.
*/
export type PresenceWrite = {
// [Gitea #283] or MSC3026 busy (Do Not Disturb)
presence: 'online' | 'offline' | 'unavailable' | typeof BUSY_PRESENCE;
status_msg?: string;
};
/**
* `mx.setPresence` for a PresenceWrite. The SDK refuses anything but the three
* spec presences ("Bad presence value"), so MSC3026 busy (#283) goes straight
* to the same endpoint.
*/
export const writePresence = async (
mx: Pick<MatrixClient, 'setPresence'> & Partial<Pick<MatrixClient, 'getSafeUserId' | 'http'>>,
write: PresenceWrite,
): Promise<void> => {
if (write.presence !== BUSY_PRESENCE) {
await mx.setPresence(write as IPresenceOpts);
return;
}
if (!mx.getSafeUserId || !mx.http) throw new Error('busy presence needs a full MatrixClient');
const path = `/presence/${encodeURIComponent(mx.getSafeUserId())}/status`;
await mx.http.authedRequest(Method.Put, path, undefined, write);
};
export type PresenceWrite = { presence: 'online' | 'offline' | 'unavailable'; status_msg?: string };
const sleep = (ms: number) =>
new Promise<void>((r) => {
@@ -51,7 +28,7 @@ export async function setPresenceWithRetry(
for (;;) {
try {
// eslint-disable-next-line no-await-in-loop
await writePresence(mx, write);
await mx.setPresence(write);
return;
} catch (e) {
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
+39
View File
@@ -928,3 +928,42 @@ test('getAllVersionsRoomCreator returns empty set when no create event', () => {
test('EventTimeline.FORWARDS constant is available', () => {
assert.equal(typeof EventTimeline.FORWARDS, 'string');
});
test('getUnreadInfo: unfollowed threads do not light the room; followed and mentioned do (#314)', () => {
const threads = [
{ id: '$followed', hasCurrentUserParticipated: true },
{ id: '$other' },
{ id: '$mention' },
];
const counts: Record<string, { total: number; highlight: number }> = {
$followed: { total: 2, highlight: 0 },
$other: { total: 3, highlight: 0 },
$mention: { total: 1, highlight: 1 },
};
const room = {
roomId: '!r:x',
// Server room totals include thread counts: 2 + 3 + 1 thread, 0 main.
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? 6 : 1,
getThreads: () => threads,
getThreadUnreadNotificationCount: (id: string, type: NotificationCountType) =>
type === NotificationCountType.Total ? counts[id].total : counts[id].highlight,
getEventReadUpTo: () => null,
getLiveTimeline: () => ({ getEvents: () => [] }),
} as unknown as Room;
const me = { getUserId: () => '@me:x' } as unknown as MatrixClient;
assert.deepEqual(getUnreadInfo(room, undefined, me), { roomId: '!r:x', highlight: 1, total: 3 });
// Only an unfollowed thread is unread: the room isn't lit.
counts.$followed.total = 0;
counts.$mention = { total: 0, highlight: 0 };
const onlyOther = {
...room,
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? 3 : 0,
} as unknown as Room;
assert.deepEqual(getUnreadInfo(onlyOther, undefined, me), {
roomId: '!r:x',
highlight: 0,
total: 0,
});
});
+27
View File
@@ -18,6 +18,7 @@ import {
NotificationCountType,
RelationType,
Room,
Thread,
RoomMember,
TimelineEvents,
IRoomTimelineData,
@@ -418,6 +419,20 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
/**
* [Gitea #217] Threads the user is following: they started it, replied in it,
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
* thread bundle and lags a reply we just sent, so our own loaded events count too.
*/
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
if (thread.hasCurrentUserParticipated) return true;
if (userId && thread.rootEvent?.getSender() === userId) return true;
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
return (
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
);
}
export const getUnreadInfo = (
room: Room,
mutedThreads?: Set<string>,
@@ -439,6 +454,18 @@ export const getUnreadInfo = (
if (highlight < 0) highlight = 0;
}
// [Gitea #314] Threads you don't follow stay unread in the Threads panel
// (viewing the room no longer reads them) but don't light the room's dot.
// A mention makes a thread followed, so highlights are never subtracted.
if (mx) {
room.getThreads().forEach((thread) => {
if (mutedThreads?.has(thread.id)) return;
if (isThreadFollowed(room, thread, mx.getUserId())) return;
total -= room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
});
if (total < 0) total = 0;
}
const resolvedTotal = highlight > total ? highlight : total;
// Suppress a spurious Total when the room isn't really unread. Two safe cases,