feat(native): a link shared to TREK can go to the trip's links

A page or a link shared from another app now carries its address. The app asks whether to keep it among the trip's links (Collab, Links) or to read a booking from it. A link whose page the phone cannot read is no longer refused: it is handed over as the link alone, which can be kept. With Collab off a page is read as a booking, as before.
This commit is contained in:
Maurice
2026-11-08 10:15:26 +01:00
parent afaacec61a
commit 3458238db0
40 changed files with 617 additions and 59 deletions
+56 -12
View File
@@ -1,20 +1,33 @@
import { create } from 'zustand';
import { collabApi } from '../api/client';
import { hasNativeFeature, loadNativeCapabilities } from './capabilities';
import { reportNativeFailure } from './reportFailure';
import { TrekShell, isUnimplemented, type SharedFilePayload } from './trekShell';
/**
* Booking documents shared to TREK ("Open in TREK" on a PDF, a calendar entry,
* a Wallet pass or a mail). The native side hands them over as base64
* (TrekShell.takeSharedFile and the sharedFile event); here they become a File
* waiting for a trip: NativeImportSheet asks which one, and the trip's booking
* import opens with the file already chosen (BookingImportModal).
* What was shared to TREK: a booking document ("Open in TREK" on a PDF, a
* calendar entry, a Wallet pass or a mail), or a web page or link. The native
* side hands it over as base64 (TrekShell.takeSharedFile and the sharedFile
* event); here it becomes a File waiting for a trip: NativeImportSheet asks
* which one, and the trip's booking import opens with the file already chosen
* (BookingImportModal). A page or a link also carries its address, and the
* sheet asks first whether it goes to the trip's links or is read as a booking.
*/
/** A shared page or link, which can be kept among a trip's links. */
export interface SharedLink {
url: string;
title: string;
/** The trip the share sheet picked for it, held back until it is clear what to do with the link. */
presetTripId: number | null;
}
interface SharedImportState {
file: File | null;
/** The trip the user picked for it; the planner of that trip takes it from here. */
/** The trip whose booking import takes the file; the planner of that trip takes it from here. */
tripId: number | null;
setFile: (file: File | null) => void;
link: SharedLink | null;
setFile: (file: File | null, link?: SharedLink | null) => void;
chooseTrip: (tripId: number) => void;
/** The booking import took the file: the queue is empty again. */
take: () => File | null;
@@ -23,11 +36,12 @@ interface SharedImportState {
export const useSharedImportStore = create<SharedImportState>((set, get) => ({
file: null,
tripId: null,
setFile: (file) => set({ file, tripId: null }),
link: null,
setFile: (file, link = null) => set({ file, tripId: null, link: file ? link : null }),
chooseTrip: (tripId) => set({ tripId }),
take: () => {
const { file } = get();
set({ file: null, tripId: null });
set({ file: null, tripId: null, link: null });
return file;
},
}));
@@ -39,6 +53,34 @@ export function toFile(payload: SharedFilePayload): File {
return new File([bytes], payload.name || 'booking', { type: payload.mimeType || 'application/octet-stream' });
}
/** The link a payload carries: an http(s) address only, titled by the page or its host. */
export function linkOf(payload: SharedFilePayload): SharedLink | null {
const raw = payload.url?.trim();
if (!raw) return null;
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
const title = payload.title?.trim() || url.host;
return {
url: url.href,
title: title.slice(0, 200),
presetTripId: typeof payload.tripId === 'number' ? payload.tripId : null,
};
}
/**
* Keeps a shared link among the trip's links (Collaboration, Links), titled as
* the page is. Online only, like adding a link in the app: the caller says so
* when it fails. The trip's other members get it live over the WebSocket.
*/
export async function saveSharedLink(tripId: number, link: SharedLink): Promise<void> {
await collabApi.createLink(tripId, { title: link.title, url: link.url });
}
/** Picks up a document shared before the UI ran, and listens for the next ones. */
export async function startSharedFiles(): Promise<void> {
await loadNativeCapabilities();
@@ -46,9 +88,11 @@ export async function startSharedFiles(): Promise<void> {
const accept = (payload: SharedFilePayload | null) => {
if (!payload?.data) return;
const store = useSharedImportStore.getState();
store.setFile(toFile(payload));
// The share sheet asked for the trip already; NativeImportSheet opens it.
if (typeof payload.tripId === 'number') store.chooseTrip(payload.tripId);
const link = linkOf(payload);
store.setFile(toFile(payload), link);
// The share sheet asked for the trip already; NativeImportSheet opens it. A link
// keeps that trip in hand instead, until the sheet knows what to do with it.
if (!link && typeof payload.tripId === 'number') store.chooseTrip(payload.tripId);
};
void TrekShell.addListener('sharedFile', accept);
try {