diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index cb5f906c5..7d3e64ca1 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -510,7 +510,19 @@ function MessageNotifications() { // For thread replies widen the tag to room:thread so each thread // coalesces independently instead of clobbering the room's bucket. 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(); diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 9c06b00e5..a2d46242c 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -278,7 +278,11 @@ const isDesktopApp = (): boolean => export const showOsNotification = async ( 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, ): Promise => { try { diff --git a/src/sw.ts b/src/sw.ts index 947d38613..b049c3680 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,6 +1,7 @@ /// import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; +import { sendNotificationReply } from './swReply'; export type {}; declare const self: ServiceWorkerGlobalScope & { @@ -147,6 +148,32 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { const data = event.notification.data ?? {}; 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( (async () => { const windowClients = ( diff --git a/src/swReply.test.ts b/src/swReply.test.ts new file mode 100644 index 000000000..c7763f5a7 --- /dev/null +++ b/src/swReply.test.ts @@ -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).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); +}); diff --git a/src/swReply.ts b/src/swReply.ts new file mode 100644 index 000000000..968918567 --- /dev/null +++ b/src/swReply.ts @@ -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 => { + const content: Record = { 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 { + 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; + } +}