feat(pwa): register as an Android share target (#155)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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/;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLDivElement, RoomInputProps>(
|
||||
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],
|
||||
|
||||
@@ -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)
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
{/* [Gitea #155] PWA share-target landing page. */}
|
||||
<Route
|
||||
path={SHARE_PAGE_PATH}
|
||||
element={
|
||||
<React.Suspense fallback={null}>
|
||||
<Share />
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={INBOX_PATH}
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
MenuItem,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import {
|
||||
roomIdToMsgDraftAtomFamily,
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
} from '../../../state/room/roomInputDrafts';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { filesToUploadItems } from '../../../utils/uploadItems';
|
||||
import {
|
||||
clearSharedPayload,
|
||||
readSharedPayload,
|
||||
shareDraftText,
|
||||
SharePayload,
|
||||
} from '../../../../swShare';
|
||||
import { BlockType } from '../../../components/editor';
|
||||
import { bytesToSize } from '../../../utils/common';
|
||||
import { getHomePath } from '../../pathUtils';
|
||||
|
||||
type Shared = { payload: SharePayload; files: File[] };
|
||||
|
||||
/**
|
||||
* [Gitea #155] Landing page for the PWA share target: what was shared, pick
|
||||
* a room, and the files land on that room's composer upload board (text/url
|
||||
* as the draft) — the user still presses Send.
|
||||
*/
|
||||
export function Share() {
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [shared, setShared] = useState<Shared | null | undefined>(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 (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon size="400" src={Icons.Send} />
|
||||
<Text size="H3" truncate>
|
||||
Share to Lotus Chat
|
||||
</Text>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
{shared === undefined && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S500 }}>
|
||||
<Spinner size="400" />
|
||||
</Box>
|
||||
)}
|
||||
{shared === null && (
|
||||
<Box direction="Column" gap="300" alignItems="Start">
|
||||
<Text size="T300">
|
||||
Nothing was shared, or it has already been placed in a room.
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => navigate(getHomePath())}
|
||||
>
|
||||
<Text size="B300">Go home</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{shared && (
|
||||
<Box direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Shared</Text>
|
||||
{shared.files.map((f) => (
|
||||
<Box key={f.name} alignItems="Center" gap="200">
|
||||
<Icon
|
||||
size="100"
|
||||
src={f.type.startsWith('image/') ? Icons.Photo : Icons.File}
|
||||
/>
|
||||
<Text size="T300" truncate>
|
||||
{f.name}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{bytesToSize(f.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{shareDraftText(shared.payload) && (
|
||||
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{shareDraftText(shared.payload)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Send to</Text>
|
||||
<Input
|
||||
size="400"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
placeholder="Search rooms"
|
||||
value={query}
|
||||
onChange={(evt) => setQuery(evt.currentTarget.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Box direction="Column" gap="100">
|
||||
{rooms.slice(0, 60).map((room) => (
|
||||
<ShareRoomRow
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
dm={directs.has(room.roomId)}
|
||||
useAuthentication={useAuthentication}
|
||||
disabled={busy}
|
||||
onPick={pick}
|
||||
/>
|
||||
))}
|
||||
{rooms.length === 0 && (
|
||||
<Text size="T300" priority="300">
|
||||
No rooms match.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(room)}
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box direction="Column">
|
||||
<Text size="T300" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
{dm && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
Direct Message
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
@@ -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<TUploadItem[]> {
|
||||
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 }));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
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;
|
||||
|
||||
@@ -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), '');
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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<void> {
|
||||
if (typeof caches === 'undefined') return;
|
||||
await caches.delete(SHARE_CACHE);
|
||||
}
|
||||
Reference in New Issue
Block a user