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
11 changed files with 145 additions and 128 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(
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.24",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.24",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.24/element-call-embedded-0.25.0-lotus.24.tgz",
"integrity": "sha512-6cMjJ0WCGvZJridoF0YslvF3giYar/uLgc/JAlgLqBg+Irv+Mm/xWuC2bme60BT+f/wmEX30gr7WDeIc4JUL7w==",
"version": "0.25.0-lotus.23",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.24",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -16,7 +16,6 @@ import {
toRem,
} from 'folds';
import { CallEmbed } from '../../plugins/call';
import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { stopPropagation } from '../../utils/keyboard';
@@ -40,21 +39,6 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
const [devices, setDevices] = useState<OutputDevice[]>([]);
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
// [Gitea #310] Prefer the fork's list: its ids are the ones valid inside
// the call iframe. Older forks don't report one; fall back to our own.
const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs);
useEffect(() => {
const onState = () => {
setForkDevices(embed.control.audioOutputs);
setSelected(embed.control.audioOutputId);
};
onState();
embed.control.on(AUDIO_OUTPUT_STATE, onState);
return () => {
embed.control.off(AUDIO_OUTPUT_STATE, onState);
};
}, [embed]);
const refresh = useCallback(async () => {
try {
const all = await navigator.mediaDevices.enumerateDevices();
@@ -69,13 +53,11 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
}, []);
useEffect(() => {
if (!anchor || forkDevices) return undefined;
if (!anchor) return undefined;
refresh();
navigator.mediaDevices.addEventListener('devicechange', refresh);
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
}, [anchor, refresh, forkDevices]);
const shown = forkDevices ?? devices;
}, [anchor, refresh]);
const choose = (id: string) => {
embed.control.setAudioOutput(id);
@@ -108,14 +90,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
aria-label="Audio output"
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{shown.length === 0 && (
{devices.length === 0 && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
No audio outputs found.
</Text>
)}
{shown.map((d) => {
const isSelected =
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
{devices.map((d) => {
const isSelected = selected ? d.id === selected : d.id === 'default';
return (
<MenuItem
key={d.id}
-40
View File
@@ -54,27 +54,6 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
};
}
export type AudioOutputOption = { id: string; label: string };
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
/** Pure: parse the fork's `io.lotus.audio_output_state` (#310). */
export function parseAudioOutputState(
data: unknown,
): { deviceId?: string; available?: AudioOutputOption[] } | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const { deviceId, available } = data as { deviceId?: unknown; available?: unknown };
const list = Array.isArray(available)
? available.filter(
(o): o is AudioOutputOption =>
typeof o === 'object' &&
o !== null &&
typeof (o as AudioOutputOption).id === 'string' &&
typeof (o as AudioOutputOption).label === 'string',
)
: undefined;
return { deviceId: typeof deviceId === 'string' ? deviceId : undefined, available: list };
}
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
@@ -238,25 +217,6 @@ export class CallControl extends EventEmitter implements CallControlState {
this.sendAudioOutput(deviceId);
}
// [Gitea #310] deviceIds are hashed per frame/origin, so ids from the
// host's own enumerateDevices() mean nothing inside the call iframe. The
// fork reports its outputs (ids valid in the frame) and its effective
// selection; the call bar's menu uses those when present.
private _audioOutputs: AudioOutputOption[] | undefined;
public get audioOutputs(): AudioOutputOption[] | undefined {
return this._audioOutputs;
}
/** The fork's `io.lotus.audio_output_state` report. */
public onAudioOutputState(data: unknown) {
const report = parseAudioOutputState(data);
if (!report) return;
this._audioOutputs = report.available;
if (report.deviceId !== undefined) this._audioOutputId = report.deviceId;
this.emit(AUDIO_OUTPUT_STATE);
}
private sendAudioOutput(deviceId: string): void {
if (!this.joined) return;
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
-6
View File
@@ -453,12 +453,6 @@ export class CallEmbed {
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #310] The fork's own output list + selection (frame-valid ids).
this.disposables.push(
this.listenAction('io.lotus.audio_output_state', (evt) => {
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
this.disposables.push(
this.listenAction('io.lotus.hotkey', (evt) => {
@@ -1,24 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseAudioOutputState } from './CallControl';
test('parseAudioOutputState: keeps valid options, tolerates garbage (#310)', () => {
assert.deepEqual(
parseAudioOutputState({
deviceId: 'f2',
available: [{ id: '', label: 'Default' }, { id: 'f2', label: 'Headset' }, { id: 3 }, null],
}),
{
deviceId: 'f2',
available: [
{ id: '', label: 'Default' },
{ id: 'f2', label: 'Headset' },
],
},
);
assert.deepEqual(parseAudioOutputState({ deviceId: null }), {
deviceId: undefined,
available: undefined,
});
assert.equal(parseAudioOutputState('x'), undefined);
});
+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.
+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,