/** * [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 => { 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 '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'; } }