Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 8e7127a350 fix(security): a dismissed status announcement no longer hides the security nudge (#307)
CI / Build & Quality Checks (pull_request) Successful in 3m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m21s
SecurityBanner asked pickBanner whether another strip would show without
the dismissed set, so a dismissed Kuma announcement (often pinned for
weeks) still counted and suppressed the verify / backup nudge on every
device that had dismissed it. The dismissed set is now a shared jotai
atom (still persisted to localStorage) used by both banners. Adds a
Playwright regression.

Fixes #307

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:26:54 -04:00
6 changed files with 102 additions and 113 deletions
-46
View File
@@ -55,52 +55,6 @@ test.describe('local homeserver regression', () => {
expect(console_.unexpected()).toEqual([]);
});
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Poll Room');
await loginUI(page, alice);
await openRoom(page, room);
const question = uniq('Lunch? ');
await page.getByRole('button', { name: 'Create poll' }).first().click();
await page.getByPlaceholder('Ask a question…').fill(question);
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
await page.getByRole('button', { name: 'Create Poll' }).last().click();
const poll = page.locator('[data-message-id]', { hasText: question }).last();
await expect(poll).toBeVisible();
type Ev = {
type: string;
event_id: string;
content: { 'm.relates_to'?: { event_id?: string } };
};
const events = async () =>
(
await api<{ chunk: Ev[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
alice.token,
)
).chunk;
// Vote once the poll has reached the server (its row may still carry the local id).
await expect
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
.toBe(true);
await poll.getByText('Tacos', { exact: true }).first().click();
await expect
.poll(async () => {
const chunk = await events();
const start = chunk.find((e) => /poll\.start/.test(e.type));
return chunk.some(
(e) =>
/poll\.response/.test(e.type) &&
e.content['m.relates_to']?.event_id === start?.event_id &&
start?.event_id.startsWith('$'),
);
})
.toBe(true);
expect(console_.unexpected()).toEqual([]);
});
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
const room = await createRoom(alice, 'Scroll Room');
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
+42
View File
@@ -29,6 +29,32 @@ async function settled(page: Page) {
await page.waitForTimeout(2000);
}
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
async function mockAnnouncement(page: Page) {
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const cors = { 'access-control-allow-origin': '*' };
if (route.request().url().includes('/heartbeat/')) {
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
maintenanceList: [],
publicGroupList: [],
},
});
});
}
test.describe('security nudge (#110, #123)', () => {
let user: TestUser;
@@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => {
await expect(page.getByText('Device Verification').first()).toBeVisible();
});
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
test.setTimeout(200_000);
await mockAnnouncement(page);
await loginUI(page, user);
await pastGrace(page);
await settled(page);
// The announcement wins the one strip slot until it's dismissed.
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
await expect(announcement).toBeVisible();
await expect(strip(page)).toHaveCount(0);
await announcement.getByRole('button', { name: 'Dismiss' }).click();
await page.reload();
await settled(page);
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
});
test('"Not now" snoozes it across a reload', async ({ page }) => {
test.setTimeout(200_000);
await loginUI(page, user);
@@ -1,7 +1,7 @@
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName, sendRoomEvent } from '../../../utils/room';
import {
@@ -99,21 +99,7 @@ export function PollContent({
}) {
const mx = useMatrixClient();
const roomId = room.roomId;
// [Gitea #284] A poll you just created starts with its local-echo id
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
// doesn't re-render us. Track the id so votes and the tally use the real one.
const [eventId, setEventId] = useState(() => mEvent.getId());
useEffect(() => {
const sync = () => setEventId(mEvent.getId());
sync();
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.on(MatrixEventEvent.Status, sync);
return () => {
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.off(MatrixEventEvent.Status, sync);
};
}, [mEvent]);
const isLocalEcho = !!eventId?.startsWith('~');
const eventId = mEvent.getId();
const senderId = mEvent.getSender();
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
@@ -206,9 +192,8 @@ export function PollContent({
const isMultiple = maxSelections > 1;
const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
const canEnd =
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
const canVote = !!roomId && !!eventId && !isEnded;
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes;
const { counts, total, voters } = tally;
@@ -234,34 +219,25 @@ export function PollContent({
setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces).
// The SDK can throw synchronously (e.g. a relation to a pending event).
try {
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
} catch {
setPending(null);
}
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
};
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
try {
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
})
.then(() => {
setConfirmEnd(false);
setEnding(false);
})
.then(() => {
setConfirmEnd(false);
setEnding(false);
})
.catch(() => setEnding(false));
} catch {
setEnding(false);
}
.catch(() => setEnding(false));
};
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
import { useAlive } from '../../hooks/useAlive';
import { useSyncState } from '../../hooks/useSyncState';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { pickBanner } from '../../utils/kumaStatus';
import {
dismissNudge,
@@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
const backupInfo = useKeyBackupInfo(crypto);
const backupActive = useBackupActive(crypto);
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
const statusDismissed = useAtomValue(statusDismissedAtom);
const requestSettings = useSetAtom(settingsRequestAtom);
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
// never stacks under the "Connecting…" strip at startup.
@@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
});
// One strip at a time: outages, maintenance and a lost connection win.
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
// on screen and must not keep the nudge hidden for as long as it's posted.
const otherStrip =
syncLost ||
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
return null;
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
@@ -7,21 +7,9 @@ import { useClientConfig } from '../../hooks/useClientConfig';
import { useSyncState } from '../../hooks/useSyncState';
import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const bannerIcon = (b: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting;
if (b.tone === 'Primary') return Icons.Info;
@@ -173,22 +161,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
/** The logged-in app's status strip. */
export function ServerStatusBanner() {
const { status, syncLost } = useAtomValue(serverStatusAtom);
const [dismissed, setDismissed] = useState(readDismissed);
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
const banner = pickBanner(status, {
syncLost,
where: 'client',
dismissed: new Set(dismissed),
});
if (!banner) return null;
const dismiss = () => {
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
setDismissed(next);
};
const dismiss = () => dismissKey(banner.key);
return <StatusStrip banner={banner} onDismiss={dismiss} />;
}
+32
View File
@@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{
syncLost: false,
lostAt: null,
});
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const baseDismissedAtom = atom<string[]>(readDismissed());
/**
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
* the security nudge knows a dismissed announcement isn't on screen.
* Persisted to localStorage; session-only if storage is unavailable.
*/
export const statusDismissedAtom = atom(
(get) => get(baseDismissedAtom),
(get, set, key: string) => {
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
set(baseDismissedAtom, next);
},
);