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
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
/**
|
|
* [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
|
|
* the notification account's session and the global fetch.
|
|
*/
|
|
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': {} };
|
|
if (threadId) {
|
|
content['m.relates_to'] = {
|
|
rel_type: 'm.thread',
|
|
event_id: threadId,
|
|
is_falling_back: true,
|
|
'm.in_reply_to': { event_id: threadId },
|
|
};
|
|
}
|
|
return content;
|
|
};
|
|
|
|
export const replySendUrl = (baseUrl: string, roomId: string, txnId: string): string =>
|
|
new URL(
|
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
|
|
baseUrl,
|
|
).href;
|
|
|
|
export async function sendNotificationReply(
|
|
session: SwReplySession | undefined,
|
|
roomId: string,
|
|
threadId: string | undefined,
|
|
text: string,
|
|
fetchFn: typeof fetch = fetch,
|
|
): 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: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(buildReplyContent(text, threadId)),
|
|
});
|
|
return res.ok ? 'sent' : 'failed';
|
|
} catch {
|
|
return 'failed';
|
|
}
|
|
}
|