Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 005fc9e768 fix(notifications): notification replies re-check encryption at send time and use the right account (#299)
CI / Build & Quality Checks (pull_request) Successful in 2m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m49s
The service worker posts inline notification replies in the clear (it
cannot encrypt). The Reply action was only withheld for rooms that were
encrypted when the notification was shown, so a reply typed hours later
into a room that has since turned on encryption went out unencrypted.
It also posted with the newest session the worker knew, not the
notification's account.

The worker now GETs the room's m.room.encryption state first and only
sends on a definite 404; notifications carry the user id and the worker
uses that account's session (or refuses). "Reply not sent" says why.

Fixes #299

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:52:12 -04:00
8 changed files with 136 additions and 150 deletions
+2 -95
View File
@@ -1,7 +1,7 @@
# Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
Last updated: October 2026.
Last updated: July 2026.
---
@@ -28,8 +28,7 @@ Last updated: October 2026.
19. [Infrastructure](#infrastructure)
20. [Localization](#localization)
21. [Desktop App Features](#desktop-app-features)
22. [June–October 2026 Additions](#juneoctober-2026-additions)
23. [Key Custom Files](#key-custom-files)
22. [Key Custom Files](#key-custom-files)
---
@@ -1523,98 +1522,6 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri
---
## June–October 2026 Additions
Features shipped since the sections above were last updated (Gitea #294). Each
line names the commit that introduced it.
### Calls
- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`).
- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`).
- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`).
- **Mic level meter** on the mute button (`3449338b`, #146).
- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117).
- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128).
- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223).
- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134).
- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157).
- **Live-call dot on the space rail** (`4fe9c870`, #148).
- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162).
- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135).
- **Audio output quick-switch** in the call bar (`98c80f36`, #119).
- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143).
- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`).
- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43).
### Messaging & composer
- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138).
- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137).
- **Inline audio playback** for `m.file` audio attachments (`eb34b047`).
- **Recently used GIFs** in the GIF picker (`5b58e5fe`).
- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129).
- **Offer to format pasted code** as a code block (`75d55e86`, #107).
- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147).
- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122).
- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200).
- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127).
- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112).
- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`).
### Search & navigation
- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`).
- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`).
- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106).
- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`).
### Rooms & threads
- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`).
- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`).
- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133).
- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165).
### Media viewer
- **Swipe** between items in the full-screen viewer (`cf80729a`, #164).
- **Two-finger pinch zoom** (`39589b40`, #164).
### Profile, privacy & safety
- **Report User** (MSC4260) (`26f90087`).
- **Active now / Last active** from presence (`dea1f7af`, #150).
- **Delete all my messages** in a room (`edb46247`, #169).
- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214).
- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123).
- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124).
### Appearance
- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1).
- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2).
### Mobile & PWA
- **Saved Messages** from the room header's More Options menu (`8c711f5f`).
- **Long-press bottom sheet** of message actions (`53a80adb`, #166).
- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125).
- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155).
### Notifications
- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203).
### Desktop app
- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4).
- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3).
- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5).
- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105).
---
## Key Custom Files
| File | Purpose |
+1 -1
View File
@@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
+3 -2
View File
@@ -571,7 +571,8 @@ function MessageNotifications() {
// NotificationOptions typing.
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
}),
data: { path: roomPath, roomId, threadId: threadId || undefined },
// userId: [Gitea #299] the SW replies as this account only.
data: { path: roomPath, roomId, threadId: threadId || undefined, userId: mx.getUserId() },
},
() => {
window.focus();
@@ -579,7 +580,7 @@ function MessageNotifications() {
},
);
},
[navigate, setToast, mDirects],
[navigate, setToast, mDirects, mx],
);
// N105: when a service-worker-owned notification is clicked, the SW focuses
+1 -1
View File
@@ -43,7 +43,7 @@ if ('serviceWorker' in navigator && swProtocolOk) {
const sendSessionToSW = () => {
const session = getFallbackSession();
pushSessionToSW(session?.baseUrl, session?.accessToken);
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
};
// The dev worker is an ES module (it imports workbox), so it must be
+2 -1
View File
@@ -1,4 +1,4 @@
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) {
if (!('serviceWorker' in navigator)) return;
if (!navigator.serviceWorker.controller) return;
@@ -6,5 +6,6 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
type: 'setSession',
accessToken,
baseUrl,
userId,
});
}
+19 -9
View File
@@ -2,7 +2,7 @@
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { sendNotificationReply } from './swReply';
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
export type {};
declare const self: ServiceWorkerGlobalScope & {
@@ -40,6 +40,8 @@ precacheAndRoute(self.__WB_MANIFEST);
type SessionInfo = {
accessToken: string;
baseUrl: string;
/** [Gitea #299] whose session this is, so a notification reply uses its own account */
userId?: string;
};
/**
@@ -63,9 +65,13 @@ async function cleanupDeadClients() {
});
}
function setSession(clientId: string, accessToken: string, baseUrl: string) {
function setSession(clientId: string, accessToken: string, baseUrl: string, userId?: unknown) {
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(clientId, { accessToken, baseUrl });
sessions.set(clientId, {
accessToken,
baseUrl,
userId: typeof userId === 'string' ? userId : undefined,
});
} else {
// Logout or invalid session
sessions.delete(clientId);
@@ -130,10 +136,10 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
const client = event.source as Client | null;
if (!client) return;
const { type, accessToken, baseUrl } = event.data || {};
const { type, accessToken, baseUrl, userId } = event.data || {};
if (type === 'setSession') {
setSession(client.id, accessToken, baseUrl);
setSession(client.id, accessToken, baseUrl, userId);
cleanupDeadClients();
}
});
@@ -155,15 +161,19 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
if (text) {
event.waitUntil(
sendNotificationReply(
Array.from(sessions.values()).pop(),
// [Gitea #299] The notification's own account, not the newest session.
pickReplySession(
Array.from(sessions.values()),
typeof data.userId === 'string' ? data.userId : undefined,
),
data.roomId,
data.threadId,
text,
).then((ok) => {
if (!ok) {
).then((result) => {
if (result !== 'sent') {
// Let the user know it didn't go out, and keep their text reachable.
return self.registration.showNotification('Reply not sent', {
body: `Couldn't send "${text.slice(0, 60)}" — open the app to retry.`,
body: replyFailureText(result, text),
tag: `swreply-fail-${data.roomId}`,
data: { path },
});
+60 -30
View File
@@ -1,6 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildReplyContent, replySendUrl, sendNotificationReply } from './swReply';
import {
buildReplyContent,
pickReplySession,
replyFailureText,
replySendUrl,
sendNotificationReply,
} from './swReply';
test('thread replies carry the m.thread relation, room replies do not', () => {
assert.deepEqual(buildReplyContent('hi'), { msgtype: 'm.text', body: 'hi', 'm.mentions': {} });
@@ -19,40 +25,64 @@ test('send URL encodes the room id against the session base URL', () => {
);
});
test('sendNotificationReply PUTs with the bearer token and reports ok / failure', async () => {
test('sendNotificationReply checks encryption, then PUTs with the bearer token (#203, #299)', async () => {
const calls: { url: string; init: RequestInit }[] = [];
const fetchOk = (async (url: string, init: RequestInit) => {
calls.push({ url, init });
return { ok: true } as Response;
}) as unknown as typeof fetch;
assert.equal(
await sendNotificationReply(
{ accessToken: 'tok', baseUrl: 'https://hs' },
'!r:hs',
'$t',
'yo',
fetchOk,
),
true,
);
assert.equal(calls[0].init.method, 'PUT');
assert.equal((calls[0].init.headers as Record<string, string>).Authorization, 'Bearer tok');
const fetchFor = (encStatus: number, sendOk = true) =>
(async (url: string, init: RequestInit = {}) => {
calls.push({ url, init });
if (url.includes('/state/m.room.encryption/')) {
return { ok: encStatus === 200, status: encStatus } as Response;
}
return { ok: sendOk, status: sendOk ? 200 : 500 } as Response;
}) as unknown as typeof fetch;
const session = { accessToken: 'tok', baseUrl: 'https://hs' };
assert.equal(await sendNotificationReply(session, '!r:hs', '$t', 'yo', fetchFor(404)), 'sent');
assert.match(calls[0].url, /\/rooms\/!r%3Ahs\/state\/m\.room\.encryption\/$/);
const put = calls[1];
assert.equal(put.init.method, 'PUT');
assert.equal((put.init.headers as Record<string, string>).Authorization, 'Bearer tok');
assert.match(
calls[0].url,
put.url,
/^https:\/\/hs\/_matrix\/client\/v3\/rooms\/!r%3Ahs\/send\/m\.room\.message\/swreply-/,
);
assert.equal(JSON.parse(calls[0].init.body as string)['m.relates_to'].event_id, '$t');
assert.equal(JSON.parse(put.init.body as string)['m.relates_to'].event_id, '$t');
const fetchFail = (async () => ({ ok: false }) as Response) as unknown as typeof fetch;
// Encryption turned on since the notification was shown: nothing is sent.
calls.length = 0;
assert.equal(
await sendNotificationReply(
{ accessToken: 'tok', baseUrl: 'https://hs' },
'!r:hs',
undefined,
'yo',
fetchFail,
),
false,
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(200)),
'encrypted',
);
assert.equal(calls.length, 1);
// Can't tell (server error): fail safe, nothing is sent.
calls.length = 0;
assert.equal(
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(500)),
'failed',
);
assert.equal(calls.length, 1);
assert.equal(
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(404, false)),
'failed',
);
assert.equal(
await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchFor(404)),
'no-session',
);
assert.equal(await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchOk), false);
});
test('pickReplySession: the notification account only (#299)', () => {
const a = { accessToken: 'a', baseUrl: 'https://hs', userId: '@a:hs' };
const b = { accessToken: 'b', baseUrl: 'https://hs', userId: '@b:hs' };
assert.equal(pickReplySession([a, b], '@a:hs'), a);
assert.equal(pickReplySession([b], '@a:hs'), undefined, 'logged out of @a here');
assert.equal(pickReplySession([a], undefined), a, 'old notification, one session');
assert.equal(pickReplySession([a, b], undefined), undefined, 'old notification, ambiguous');
});
test('replyFailureText says why', () => {
assert.match(replyFailureText('encrypted', 'hi'), /now encrypted/);
assert.match(replyFailureText('no-session', 'hi'), /no longer signed in/);
assert.match(replyFailureText('failed', 'hi'), /retry/);
});
+48 -11
View File
@@ -1,9 +1,42 @@
/**
* [Gitea #203] Build and send an inline reply typed into a notification's
* text action. Lives outside sw.ts so it can be unit-tested; the SW passes in
* its newest known session and the global fetch.
* the notification account's session and the global fetch.
*/
export type SwReplySession = { accessToken: string; baseUrl: string };
export type SwReplySession = { accessToken: string; baseUrl: string; userId?: string };
export type SwReplyResult = 'sent' | 'failed' | 'encrypted' | 'no-session';
/**
* [Gitea #299] The session to reply with: the notification's own account. A
* notification without a user id (shown before this change) may only use the
* session if it is the only one, so a reply never posts as another account.
*/
export const pickReplySession = (
sessions: SwReplySession[],
userId: string | undefined,
): SwReplySession | undefined => {
if (userId) return sessions.filter((x) => x.userId === userId).pop();
return sessions.length === 1 ? sessions[0] : undefined;
};
/** What the "Reply not sent" notification says for each failure. */
export const replyFailureText = (result: SwReplyResult, text: string): string => {
const quoted = `"${text.slice(0, 60)}"`;
if (result === 'encrypted') {
return `This room is now encrypted, so ${quoted} wasn't sent. Open the app to reply.`;
}
if (result === 'no-session') {
return `You're no longer signed in to that account here, so ${quoted} wasn't sent.`;
}
return `Couldn't send ${quoted}. Open the app to retry.`;
};
export const encryptionStateUrl = (baseUrl: string, roomId: string): string =>
new URL(
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`,
baseUrl,
).href;
export const buildReplyContent = (text: string, threadId?: string): Record<string, unknown> => {
const content: Record<string, unknown> = { msgtype: 'm.text', body: text, 'm.mentions': {} };
@@ -30,20 +63,24 @@ export async function sendNotificationReply(
threadId: string | undefined,
text: string,
fetchFn: typeof fetch = fetch,
): Promise<boolean> {
if (!session?.accessToken || !session.baseUrl) return false;
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
): Promise<SwReplyResult> {
if (!session?.accessToken || !session.baseUrl) return 'no-session';
const headers = { Authorization: `Bearer ${session.accessToken}` };
try {
// [Gitea #299] The SW can't encrypt, and the reply action was offered when
// the notification was shown — possibly hours ago. Re-check now: only a
// definite "no encryption state" (404) lets the plain reply go out.
const enc = await fetchFn(encryptionStateUrl(session.baseUrl, roomId), { headers });
if (enc.ok) return 'encrypted';
if (enc.status !== 404) return 'failed';
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), {
method: 'PUT',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(buildReplyContent(text, threadId)),
});
return res.ok;
return res.ok ? 'sent' : 'failed';
} catch {
return false;
return 'failed';
}
}