From 96a97a2f86923818ffd3e72ba16dd4abfc910ab1 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 13:36:05 -0400 Subject: [PATCH] feat(pwa): register as an Android share target (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public/manifest.json declares share_target (POST multipart to /share-target: title/text/url + image/video/audio/pdf/text files). The service worker answers that POST itself: it stashes the form in a Cache API bucket and 303s to the in-app /share page, which lists what arrived, offers a room search, and on pick writes the files into that room's upload-board atom (encrypting first for E2EE rooms via the composer's shared filesToUploadItems) and the title/text/url into its draft, then opens the room — the user still presses Send. The stash is cleared once placed; reopening /share afterwards says so. nginx/caddy examples and the prod image config gain a 303 for /share-target so a POST that reaches the origin before the worker controls the page lands on /share instead of a 405. iOS has no share target support and ignores the manifest entry. Verified headless against the built preview: SW-controlled page → POST /share-target (two PNGs + title + text) → /share lists both files and the text → pick the DM → composer shows both files on the upload board and the text in the draft → /share reports nothing pending. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- contrib/caddy/caddyfile | 4 + contrib/nginx/cinny.domain.tld.conf | 7 + public/manifest.json | 16 ++ src/app/features/room/RoomInput.tsx | 31 +--- src/app/pages/Router.tsx | 13 ++ src/app/pages/client/share/Share.tsx | 247 +++++++++++++++++++++++++++ src/app/utils/uploadItems.ts | 22 +++ src/sw.ts | 11 ++ src/swShare.test.ts | 27 +++ src/swShare.ts | 88 ++++++++++ 10 files changed, 437 insertions(+), 29 deletions(-) create mode 100644 src/app/pages/client/share/Share.tsx create mode 100644 src/app/utils/uploadItems.ts create mode 100644 src/swShare.test.ts create mode 100644 src/swShare.ts diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile index 666d2d6e9..dc561bec1 100644 --- a/contrib/caddy/caddyfile +++ b/contrib/caddy/caddyfile @@ -1,6 +1,10 @@ # more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas cinny.domain.tld { root * /path/to/cinny/dist + # [Gitea #155] PWA share target: the service worker answers this POST; if it + # isn't controlling the page yet, land on /share instead of a 405. + redir /share-target /share 303 + try_files {path} /index.html file_server diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf index 3d3562ad2..4fa4c82c9 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/cinny.domain.tld.conf @@ -26,6 +26,13 @@ server { add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always; + # [Gitea #155] PWA share target. The service worker normally answers this + # POST itself; if it isn't controlling the page yet, land on /share + # (the shared files are lost, but nothing 405s). + location = /share-target { + return 303 /share; + } + location / { root /opt/cinny/dist/; diff --git a/public/manifest.json b/public/manifest.json index 46897b2b2..c179b848b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -69,6 +69,22 @@ } ], "categories": ["social", "communication", "productivity"], + "share_target": { + "action": "./share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": ["image/*", "video/*", "audio/*", "application/pdf", "text/plain"] + } + ] + } + }, "shortcuts": [ { "name": "New Message", diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f59aee7f5..ad1462129 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -95,7 +95,7 @@ import { createUploadFamilyObserverAtom, } from '../../state/upload'; import { getImageUrlBlob, loadImageElement } from '../../utils/dom'; -import { safeFile } from '../../utils/mimeTypes'; +import { filesToUploadItems } from '../../utils/uploadItems'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; import { useAlive } from '../../hooks/useAlive'; @@ -375,36 +375,9 @@ export const RoomInput = forwardRef( const handleFiles = useCallback( async (files: File[]) => { setUploadBoard(true); - const safeFiles = files.map(safeFile); - const fileItems: TUploadItem[] = []; - - if (room.hasEncryptionStateEvent()) { - const encryptFiles = fulfilledPromiseSettledResult( - await Promise.allSettled(safeFiles.map((f) => encryptFile(f))), - ); - encryptFiles.forEach((ef) => - fileItems.push({ - ...ef, - metadata: { - markedAsSpoiler: false, - }, - }), - ); - } else { - safeFiles.forEach((f) => - fileItems.push({ - file: f, - originalFile: f, - encInfo: undefined, - metadata: { - markedAsSpoiler: false, - }, - }), - ); - } setSelectedFiles({ type: 'PUT', - item: fileItems, + item: await filesToUploadItems(room, files), }); }, [setSelectedFiles, room], diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 29e7ebd46..8503238c0 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -63,10 +63,14 @@ import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer'; import { HomeCreateRoom } from './client/home/CreateRoom'; import { Create } from './client/create'; import { getFallbackSession } from '../state/sessions'; +import { SHARE_PAGE_PATH } from '../../swShare'; + import { RouteError } from './RouteError'; import { CallStatusRenderer } from './CallStatusRenderer'; import { CallEmbedProvider } from '../components/CallEmbedProvider'; +const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share }))); + const AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout }))); const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login }))); const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register }))); @@ -380,6 +384,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> + {/* [Gitea #155] PWA share-target landing page. */} + + + + } + /> (undefined); + const [query, setQuery] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + readSharedPayload() + .then((s) => setShared(s ?? null)) + .catch(() => setShared(null)); + }, []); + + const rooms = useMemo(() => { + const all = mx + .getRooms() + .filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom()) + .sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)); + const q = query.trim().toLowerCase(); + return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all; + }, [mx, query]); + + // Written straight into the room's draft atoms so its composer shows the + // upload board + text as soon as it mounts. + const store = useStore(); + const pick = useCallback( + async (room: Room) => { + if (!shared || busy) return; + setBusy(true); + try { + if (shared.files.length) { + store.set(roomIdToUploadItemsAtomFamily(room.roomId), { + type: 'PUT', + item: await filesToUploadItems(room, shared.files), + }); + } + const text = shareDraftText(shared.payload); + if (text) { + store.set(roomIdToMsgDraftAtomFamily(room.roomId), [ + { type: BlockType.Paragraph, children: [{ text }] }, + ]); + } + await clearSharedPayload(); + navigateRoom(room.roomId); + } finally { + setBusy(false); + } + }, + [shared, busy, navigateRoom, store], + ); + + return ( + + + + + + Share to Lotus Chat + + + + + + + {shared === undefined && ( + + + + )} + {shared === null && ( + + + Nothing was shared, or it has already been placed in a room. + + + + )} + {shared && ( + + + Shared + {shared.files.map((f) => ( + + + + {f.name} + + + {bytesToSize(f.size)} + + + ))} + {shareDraftText(shared.payload) && ( + + {shareDraftText(shared.payload)} + + )} + + + Send to + setQuery(evt.currentTarget.value)} + autoFocus + /> + + {rooms.slice(0, 60).map((room) => ( + + ))} + {rooms.length === 0 && ( + + No rooms match. + + )} + + + + )} + + + + + ); +} + +function ShareRoomRow({ + room, + dm, + useAuthentication, + disabled, + onPick, +}: { + room: Room; + dm: boolean; + useAuthentication: boolean; + disabled: boolean; + onPick: (room: Room) => void; +}) { + const mx = useMatrixClient(); + const avatarMxc = room.getMxcAvatarUrl(); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined) + : undefined; + return ( + onPick(room)} + before={ + + ( + + )} + /> + + } + > + + + {room.name} + + {dm && ( + + Direct Message + + )} + + + ); +} diff --git a/src/app/utils/uploadItems.ts b/src/app/utils/uploadItems.ts new file mode 100644 index 000000000..026bb65e3 --- /dev/null +++ b/src/app/utils/uploadItems.ts @@ -0,0 +1,22 @@ +import { Room } from 'matrix-js-sdk'; +import { TUploadItem } from '../state/room/roomInputDrafts'; +import { encryptFile } from './matrix'; +import { safeFile } from './mimeTypes'; +import { fulfilledPromiseSettledResult } from './common'; + +/** + * Files → composer upload items for `room`, encrypting first when the room + * is E2EE. Shared by the composer's pick/drop/paste paths and the PWA share + * target (#155). + */ +export async function filesToUploadItems(room: Room, files: File[]): Promise { + const safeFiles = files.map(safeFile); + const metadata = { markedAsSpoiler: false }; + if (room.hasEncryptionStateEvent()) { + const encrypted = fulfilledPromiseSettledResult( + await Promise.allSettled(safeFiles.map((f) => encryptFile(f))), + ); + return encrypted.map((ef) => ({ ...ef, metadata })); + } + return safeFiles.map((f) => ({ file: f, originalFile: f, encInfo: undefined, metadata })); +} diff --git a/src/sw.ts b/src/sw.ts index b049c3680..4f8789e4b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,6 +1,7 @@ /// import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; +import { SHARE_TARGET_PATH, stashSharedForm } from './swShare'; import { sendNotificationReply } from './swReply'; export type {}; @@ -234,6 +235,16 @@ function fetchConfig(token: string): RequestInit { self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; + // [Gitea #155] Android share sheet → POST /share-target (manifest + // share_target). Stash the form and bounce to the in-app /share page. + if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) { + const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length); + event.respondWith( + stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)), + ); + return; + } + if (method !== 'GET' || !mediaPath(url)) return; const { clientId } = event; diff --git a/src/swShare.test.ts b/src/swShare.test.ts new file mode 100644 index 000000000..69147e063 --- /dev/null +++ b/src/swShare.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { shareDraftText } from './swShare'; + +const base = { files: [], receivedAt: 0 }; + +test('shareDraftText joins title, text and url', () => { + assert.equal( + shareDraftText({ ...base, title: 'A page', text: 'look at this', url: 'https://x.test/p' }), + 'A page\nlook at this\nhttps://x.test/p', + ); +}); + +test('shareDraftText drops a title/url the text already contains', () => { + assert.equal( + shareDraftText({ + ...base, + title: 'A page', + text: 'A page https://x.test/p', + url: 'https://x.test/p', + }), + 'A page https://x.test/p', + ); + assert.equal(shareDraftText({ ...base, text: 'from the share sheet' }), 'from the share sheet'); + assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test'); + assert.equal(shareDraftText(base), ''); +}); diff --git a/src/swShare.ts b/src/swShare.ts new file mode 100644 index 000000000..f8b632a04 --- /dev/null +++ b/src/swShare.ts @@ -0,0 +1,88 @@ +/** + * [Gitea #155] Web Share Target plumbing shared by the service worker and the + * app. Android's share sheet POSTs a multipart form to /share-target (see + * public/manifest.json); the worker stashes the payload in the Cache API and + * redirects to the in-app /share page, which reads it back, lets the user + * pick a room and pre-attaches the files to that room's composer. + */ + +export const SHARE_TARGET_PATH = '/share-target'; +export const SHARE_PAGE_PATH = '/share'; +export const SHARE_CACHE = 'lotus-share-target'; +export const SHARE_PAYLOAD_KEY = '/share-target/payload'; +export const shareFileKey = (index: number) => `/share-target/file/${index}`; + +export type SharePayload = { + title?: string; + text?: string; + url?: string; + files: { name: string; type: string; size: number }[]; + receivedAt: number; +}; + +/** Worker side: stash the POSTed form and answer with a redirect to /share. */ +export async function stashSharedForm(request: Request, base: string): Promise { + const form = await request.formData(); + const files = form.getAll('files').filter((f): f is File => f instanceof File); + const str = (k: string) => { + const v = form.get(k); + return typeof v === 'string' && v.trim() ? v : undefined; + }; + const payload: SharePayload = { + title: str('title'), + text: str('text'), + url: str('url'), + files: files.map((f) => ({ name: f.name, type: f.type, size: f.size })), + receivedAt: Date.now(), + }; + const cache = await caches.open(SHARE_CACHE); + await Promise.all([...(await cache.keys()).map((k) => cache.delete(k))]); + await cache.put( + SHARE_PAYLOAD_KEY, + new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }), + ); + await Promise.all( + files.map((f, i) => + cache.put( + shareFileKey(i), + new Response(f, { + headers: { 'Content-Type': f.type || 'application/octet-stream' }, + }), + ), + ), + ); + return Response.redirect(`${base}${SHARE_PAGE_PATH}`, 303); +} + +/** The share sheet's title/text/url as one draft; title and url are dropped when the text already carries them. */ +export const shareDraftText = (p: SharePayload): string => { + const text = p.text?.trim() ?? ''; + const keep = (s: string | undefined) => !!s && s.trim() !== '' && !text.includes(s.trim()); + return [keep(p.title) ? p.title : '', text, keep(p.url) ? p.url : ''].filter(Boolean).join('\n'); +}; + +/** App side: read what the worker stashed (undefined when there is nothing). */ +export async function readSharedPayload(): Promise< + { payload: SharePayload; files: File[] } | undefined +> { + if (typeof caches === 'undefined') return undefined; + const cache = await caches.open(SHARE_CACHE); + const res = await cache.match(SHARE_PAYLOAD_KEY); + if (!res) return undefined; + const payload = (await res.json()) as SharePayload; + const files = await Promise.all( + payload.files.map(async (meta, i) => { + const r = await cache.match(shareFileKey(i)); + const blob = r ? await r.blob() : new Blob(); + return new File([blob], meta.name || `shared-${i + 1}`, { + type: meta.type || blob.type, + }); + }), + ); + return { payload, files }; +} + +export async function clearSharedPayload(): Promise { + if (typeof caches === 'undefined') return; + await caches.delete(SHARE_CACHE); +}