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
|
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||||
cinny.domain.tld {
|
cinny.domain.tld {
|
||||||
root * /path/to/cinny/dist
|
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
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ server {
|
|||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
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;
|
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 / {
|
location / {
|
||||||
root /opt/cinny/dist/;
|
root /opt/cinny/dist/;
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,22 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["social", "communication", "productivity"],
|
"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": [
|
"shortcuts": [
|
||||||
{
|
{
|
||||||
"name": "New Message",
|
"name": "New Message",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ import {
|
|||||||
createUploadFamilyObserverAtom,
|
createUploadFamilyObserverAtom,
|
||||||
} from '../../state/upload';
|
} from '../../state/upload';
|
||||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||||
import { safeFile } from '../../utils/mimeTypes';
|
import { filesToUploadItems } from '../../utils/uploadItems';
|
||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
@@ -375,36 +375,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
setUploadBoard(true);
|
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({
|
setSelectedFiles({
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
item: fileItems,
|
item: await filesToUploadItems(room, files),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setSelectedFiles, room],
|
[setSelectedFiles, room],
|
||||||
|
|||||||
@@ -63,10 +63,14 @@ import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
|
|||||||
import { HomeCreateRoom } from './client/home/CreateRoom';
|
import { HomeCreateRoom } from './client/home/CreateRoom';
|
||||||
import { Create } from './client/create';
|
import { Create } from './client/create';
|
||||||
import { getFallbackSession } from '../state/sessions';
|
import { getFallbackSession } from '../state/sessions';
|
||||||
|
import { SHARE_PAGE_PATH } from '../../swShare';
|
||||||
|
|
||||||
import { RouteError } from './RouteError';
|
import { RouteError } from './RouteError';
|
||||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
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 AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout })));
|
||||||
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
|
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
|
||||||
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
|
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
|
||||||
@@ -380,6 +384,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* [Gitea #155] PWA share-target landing page. */}
|
||||||
|
<Route
|
||||||
|
path={SHARE_PAGE_PATH}
|
||||||
|
element={
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<Share />
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={INBOX_PATH}
|
path={INBOX_PATH}
|
||||||
element={
|
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" />
|
/// <reference lib="WebWorker" />
|
||||||
|
|
||||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||||
|
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||||
import { sendNotificationReply } from './swReply';
|
import { sendNotificationReply } from './swReply';
|
||||||
|
|
||||||
export type {};
|
export type {};
|
||||||
@@ -234,6 +235,16 @@ function fetchConfig(token: string): RequestInit {
|
|||||||
self.addEventListener('fetch', (event: FetchEvent) => {
|
self.addEventListener('fetch', (event: FetchEvent) => {
|
||||||
const { url, method } = event.request;
|
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;
|
if (method !== 'GET' || !mediaPath(url)) return;
|
||||||
|
|
||||||
const { clientId } = event;
|
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