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:
2026-09-20 13:36:05 -04:00
co-authored by Claude Opus 5
parent 6aa77552b8
commit 96a97a2f86
10 changed files with 437 additions and 29 deletions
+2 -29
View File
@@ -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],
+13
View File
@@ -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={
+247
View File
@@ -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>
);
}
+22
View File
@@ -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 }));
}