Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
179be38e2c | ||
|
|
e4b2405a19 | ||
|
|
c8576cbb1f |
@@ -265,46 +265,6 @@ 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(
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
makePresetId,
|
||||
normalizeLabel,
|
||||
} from '../../../utils/statusPresets';
|
||||
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
@@ -508,12 +509,16 @@ function ProfileStatus() {
|
||||
if (msg && delayMs > 0) {
|
||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
|
||||
const expiresAt = Date.now() + delayMs;
|
||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt));
|
||||
// [Gitea #300] …and on every other Lotus device, via account data.
|
||||
writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, msg).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[saveStatus, userId],
|
||||
[saveStatus, userId, mx],
|
||||
);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
@@ -553,6 +558,7 @@ function ProfileStatus() {
|
||||
setStatusMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
mx.setPresence({
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
shouldNotifyThreadReply,
|
||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||
} from '../../utils/threadNotifications';
|
||||
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
|
||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||
|
||||
// Grace period after the initial sync settles before invite notifications arm, so
|
||||
@@ -436,12 +437,7 @@ function StatusExpiryMonitor() {
|
||||
// permanently and the status stayed set forever. Retry the write (honouring
|
||||
// Retry-After), and only forget the status once the server has taken it.
|
||||
let clearing = false;
|
||||
const check = () => {
|
||||
if (clearing) return;
|
||||
const stored = localStorage.getItem(expiryKey);
|
||||
if (!stored) return;
|
||||
const ts = parseInt(stored, 10);
|
||||
if (!ts || Date.now() < ts) return;
|
||||
const clearStatus = () => {
|
||||
clearing = true;
|
||||
setPresenceWithRetry(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||
@@ -451,12 +447,31 @@ function StatusExpiryMonitor() {
|
||||
noteLocalStatusWrite();
|
||||
localStorage.removeItem(msgKey);
|
||||
localStorage.removeItem(expiryKey);
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
clearing = false;
|
||||
});
|
||||
};
|
||||
const check = () => {
|
||||
if (clearing) return;
|
||||
const stored = localStorage.getItem(expiryKey);
|
||||
const ts = stored ? parseInt(stored, 10) : 0;
|
||||
if (ts && Date.now() >= ts) {
|
||||
clearStatus();
|
||||
return;
|
||||
}
|
||||
// [Gitea #300] An expiry set on another device (account data): clear the
|
||||
// status if it is still that one, otherwise just forget the expiry.
|
||||
const action = statusExpiryAction(
|
||||
readStatusExpiry(mx),
|
||||
mx.getUser(userId)?.presenceStatusMsg,
|
||||
Date.now(),
|
||||
);
|
||||
if (action === 'clear') clearStatus();
|
||||
else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
};
|
||||
|
||||
check();
|
||||
// Poll often enough that a rate-limited retry lands promptly, but not so
|
||||
|
||||
@@ -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: threads stay unread, followed or not; main receipt is scoped to main (#314)', async () => {
|
||||
test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => {
|
||||
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
|
||||
const other = thread('$other', evt('$r2'));
|
||||
const { mx, calls } = setup({
|
||||
@@ -181,9 +181,10 @@ test('passive: threads stay unread, followed or not; main receipt is scoped to m
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
// main-scoped (unthreaded would read the threads' replies), and no thread
|
||||
// receipts: [Gitea #314] unfollowed threads used to be read silently.
|
||||
[['b', false]],
|
||||
[
|
||||
['b', false], // main-scoped: unthreaded would also read $mine's reply
|
||||
['$r2', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -202,7 +203,7 @@ test('passive: threads I started or was mentioned in stay unread', async () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('passive: an unfollowed unread thread is left unread too (#314)', async () => {
|
||||
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
|
||||
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
@@ -213,16 +214,10 @@ test('passive: an unfollowed unread thread is left unread too (#314)', async ()
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[['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]],
|
||||
[
|
||||
['b', true],
|
||||
['$r2', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -254,10 +249,9 @@ 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'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { MatrixClient, NotificationCountType, ReceiptType, Thread } from 'matrix-js-sdk';
|
||||
import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
|
||||
import { getSettings } from '../state/settings';
|
||||
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
||||
import { isThreadFollowed } from './room';
|
||||
|
||||
// isThreadFollowed lives in utils/room (getUnreadInfo needs it too).
|
||||
export { isThreadFollowed };
|
||||
/**
|
||||
* [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 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 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.
|
||||
* 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.
|
||||
*/
|
||||
passive?: boolean;
|
||||
/**
|
||||
@@ -58,9 +66,7 @@ export async function markAsRead(
|
||||
const threads = room.getThreads();
|
||||
const threadUnread = (thread: Thread) =>
|
||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
// [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;
|
||||
const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId());
|
||||
// [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.
|
||||
|
||||
@@ -928,42 +928,3 @@ 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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
Room,
|
||||
Thread,
|
||||
RoomMember,
|
||||
TimelineEvents,
|
||||
IRoomTimelineData,
|
||||
@@ -419,20 +418,6 @@ 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>,
|
||||
@@ -454,18 +439,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseStatusExpiry, statusExpiryAction } from './statusExpiry';
|
||||
|
||||
test('parseStatusExpiry (#300)', () => {
|
||||
assert.deepEqual(parseStatusExpiry({ status_msg: '🍔 Lunch', expires_at: 5 }), {
|
||||
statusMsg: '🍔 Lunch',
|
||||
expiresAt: 5,
|
||||
});
|
||||
assert.equal(parseStatusExpiry({}), undefined);
|
||||
assert.equal(parseStatusExpiry({ status_msg: '', expires_at: 5 }), undefined);
|
||||
assert.equal(parseStatusExpiry({ status_msg: 'x', expires_at: '5' }), undefined);
|
||||
});
|
||||
|
||||
test('statusExpiryAction: clears only the status that was set to expire (#300)', () => {
|
||||
const e = { statusMsg: '🍔 Lunch', expiresAt: 1000 };
|
||||
assert.equal(statusExpiryAction(e, '🍔 Lunch', 999), 'none');
|
||||
assert.equal(statusExpiryAction(e, '🍔 Lunch', 1000), 'clear');
|
||||
assert.equal(statusExpiryAction(e, '🎮 Gaming', 2000), 'forget');
|
||||
assert.equal(statusExpiryAction(e, '', 2000), 'forget');
|
||||
assert.equal(statusExpiryAction(undefined, 'x', 2000), 'none');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { getAccountData } from './room';
|
||||
|
||||
/**
|
||||
* [Gitea #300] The status message's auto-clear time, in account data so every
|
||||
* Lotus device knows it. Before, only the device that set "clear after 1 hour"
|
||||
* (localStorage) ever cleared it, and only while it was open.
|
||||
*/
|
||||
export type StatusExpiry = { statusMsg: string; expiresAt: number };
|
||||
|
||||
/** Pure: parse `io.lotus.status_expiry` content. */
|
||||
export function parseStatusExpiry(content: unknown): StatusExpiry | undefined {
|
||||
const c = content as { status_msg?: unknown; expires_at?: unknown } | undefined;
|
||||
if (!c || typeof c.status_msg !== 'string' || typeof c.expires_at !== 'number') return undefined;
|
||||
if (!c.status_msg || !Number.isFinite(c.expires_at)) return undefined;
|
||||
return { statusMsg: c.status_msg, expiresAt: c.expires_at };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: what to do with a due expiry. Clear the server status only if it is
|
||||
* still the one that was set to expire; if it has since changed (e.g. on
|
||||
* another device), just forget the expiry.
|
||||
*/
|
||||
export function statusExpiryAction(
|
||||
entry: StatusExpiry | undefined,
|
||||
currentStatusMsg: string | undefined,
|
||||
now: number,
|
||||
): 'none' | 'clear' | 'forget' {
|
||||
if (!entry || now < entry.expiresAt) return 'none';
|
||||
return (currentStatusMsg ?? '') === entry.statusMsg ? 'clear' : 'forget';
|
||||
}
|
||||
|
||||
export const readStatusExpiry = (mx: MatrixClient): StatusExpiry | undefined =>
|
||||
parseStatusExpiry(getAccountData(mx, AccountDataEvent.LotusStatusExpiry)?.getContent());
|
||||
|
||||
/** Record (or with no `expiresAt`, remove) the shared auto-clear time. */
|
||||
export async function writeStatusExpiry(
|
||||
mx: MatrixClient,
|
||||
statusMsg: string,
|
||||
expiresAt?: number,
|
||||
): Promise<void> {
|
||||
const content =
|
||||
statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {};
|
||||
await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never);
|
||||
}
|
||||
@@ -26,6 +26,9 @@ export enum AccountDataEvent {
|
||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||
LotusSettings = 'io.lotus.settings',
|
||||
|
||||
// [Gitea #300] When the status message auto-clears: { status_msg, expires_at },
|
||||
// so any Lotus device can clear it (not only the one that set it).
|
||||
LotusStatusExpiry = 'io.lotus.status_expiry',
|
||||
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
|
||||
// device can lift a mute set on another.
|
||||
LotusMuteTimers = 'io.lotus.mute_timers',
|
||||
|
||||
Reference in New Issue
Block a user