feat(notifications): inline reply from a browser notification (#203)
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 2m50s
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 2m50s
Message notifications shown through the service worker now carry a text-input
'Reply' action (Chrome desktop/Android). On notificationclick with
action==='reply' the SW sends the typed text itself — it already holds the
newest session's access token for authenticated media — as m.room.message
(threaded when the notification was for a thread), so it works with the tab in
the background or closed; a failed send shows a 'Reply not sent' notification
that opens the room. Not offered for encrypted rooms (the SW cannot encrypt).
The sender lives in swReply.ts so it is unit-tested; verified headless that
the SW notification carries actions + {roomId, threadId}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -510,7 +510,19 @@ function MessageNotifications() {
|
|||||||
// For thread replies widen the tag to room:thread so each thread
|
// For thread replies widen the tag to room:thread so each thread
|
||||||
// coalesces independently instead of clobbering the room's bucket.
|
// coalesces independently instead of clobbering the room's bucket.
|
||||||
tag: threadId ? `${roomId}:${threadId}` : roomId,
|
tag: threadId ? `${roomId}:${threadId}` : roomId,
|
||||||
data: { path: roomPath },
|
// [Gitea #203] Inline reply from the notification (Chrome/Android
|
||||||
|
// text-input actions). The SW posts the reply itself — it holds the
|
||||||
|
// access token for authenticated media already — so this works with
|
||||||
|
// the tab in the background or closed. Not offered for encrypted
|
||||||
|
// rooms: the SW cannot encrypt.
|
||||||
|
...(encrypted
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
// `actions` is SW-notification-only and not in lib.dom's
|
||||||
|
// NotificationOptions typing.
|
||||||
|
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
||||||
|
}),
|
||||||
|
data: { path: roomPath, roomId, threadId: threadId || undefined },
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
window.focus();
|
window.focus();
|
||||||
|
|||||||
@@ -278,7 +278,11 @@ const isDesktopApp = (): boolean =>
|
|||||||
|
|
||||||
export const showOsNotification = async (
|
export const showOsNotification = async (
|
||||||
title: string,
|
title: string,
|
||||||
options: NotificationOptions & { data?: { path?: string } },
|
options: NotificationOptions & {
|
||||||
|
data?: { path?: string; roomId?: string; threadId?: string };
|
||||||
|
/** Chrome/Android SW notifications: buttons, incl. text-input replies. */
|
||||||
|
actions?: { action: string; title: string; type?: 'button' | 'text'; placeholder?: string }[];
|
||||||
|
},
|
||||||
onClick?: () => void,
|
onClick?: () => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/// <reference lib="WebWorker" />
|
/// <reference lib="WebWorker" />
|
||||||
|
|
||||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||||
|
import { sendNotificationReply } from './swReply';
|
||||||
|
|
||||||
export type {};
|
export type {};
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
@@ -147,6 +148,32 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
|||||||
const data = event.notification.data ?? {};
|
const data = event.notification.data ?? {};
|
||||||
const path = typeof data.path === 'string' ? data.path : undefined;
|
const path = typeof data.path === 'string' ? data.path : undefined;
|
||||||
|
|
||||||
|
// [Gitea #203] Reply action: send it and stop — no need to raise the app.
|
||||||
|
if (event.action === 'reply' && typeof data.roomId === 'string') {
|
||||||
|
const text = (event as NotificationEvent & { reply?: string }).reply?.trim();
|
||||||
|
if (text) {
|
||||||
|
event.waitUntil(
|
||||||
|
sendNotificationReply(
|
||||||
|
Array.from(sessions.values()).pop(),
|
||||||
|
data.roomId,
|
||||||
|
data.threadId,
|
||||||
|
text,
|
||||||
|
).then((ok) => {
|
||||||
|
if (!ok) {
|
||||||
|
// 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.`,
|
||||||
|
tag: `swreply-fail-${data.roomId}`,
|
||||||
|
data: { path },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const windowClients = (
|
const windowClients = (
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildReplyContent, 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': {} });
|
||||||
|
assert.deepEqual(buildReplyContent('hi', '$root')['m.relates_to'], {
|
||||||
|
rel_type: 'm.thread',
|
||||||
|
event_id: '$root',
|
||||||
|
is_falling_back: true,
|
||||||
|
'm.in_reply_to': { event_id: '$root' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('send URL encodes the room id against the session base URL', () => {
|
||||||
|
assert.equal(
|
||||||
|
replySendUrl('https://matrix.example.org/', '!r:example.org', 'tx1'),
|
||||||
|
'https://matrix.example.org/_matrix/client/v3/rooms/!r%3Aexample.org/send/m.room.message/tx1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendNotificationReply PUTs with the bearer token and reports ok / failure', 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');
|
||||||
|
assert.match(
|
||||||
|
calls[0].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');
|
||||||
|
|
||||||
|
const fetchFail = (async () => ({ ok: false }) as Response) as unknown as typeof fetch;
|
||||||
|
assert.equal(
|
||||||
|
await sendNotificationReply(
|
||||||
|
{ accessToken: 'tok', baseUrl: 'https://hs' },
|
||||||
|
'!r:hs',
|
||||||
|
undefined,
|
||||||
|
'yo',
|
||||||
|
fetchFail,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchOk), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* [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.
|
||||||
|
*/
|
||||||
|
export type SwReplySession = { accessToken: string; baseUrl: string };
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
if (!session?.accessToken || !session.baseUrl) return false;
|
||||||
|
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildReplyContent(text, threadId)),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user