From 3458238db01bdf186c3337ca5ad9a5b4d27ea488 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 10:15:26 +0100 Subject: [PATCH] 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. --- .../com/liketrek/trek/TrekSharedFiles.java | 6 + .../com/liketrek/trek/share/ShareHandoff.kt | 1 + .../java/com/liketrek/trek/share/ShareItem.kt | 20 +- .../java/com/liketrek/trek/NativeLogicTest.kt | 10 + client/ios/App/App/SceneDelegate.swift | 5 + .../ios/App/ShareExtension/ShareHandoff.swift | 4 + client/ios/App/ShareExtension/ShareItem.swift | 45 ++++- .../components/NativeImportSheet.test.tsx | 73 ++++++- .../mobile/components/NativeImportSheet.tsx | 182 +++++++++++++++--- client/src/native/sharedFiles.test.ts | 34 +++- client/src/native/sharedFiles.ts | 68 +++++-- client/src/native/trekShell.ts | 4 + shared/src/i18n/ar/native.ts | 8 + shared/src/i18n/az/native.ts | 8 + shared/src/i18n/br/native.ts | 8 + shared/src/i18n/ca/native.ts | 8 + shared/src/i18n/cs/native.ts | 8 + shared/src/i18n/de/native.ts | 8 + shared/src/i18n/en/native.ts | 8 + shared/src/i18n/es/native.ts | 8 + shared/src/i18n/et/native.ts | 8 + shared/src/i18n/fr/native.ts | 8 + shared/src/i18n/gr/native.ts | 8 + shared/src/i18n/he/native.ts | 8 + shared/src/i18n/hu/native.ts | 8 + shared/src/i18n/id/native.ts | 8 + shared/src/i18n/it/native.ts | 8 + shared/src/i18n/ja/native.ts | 8 + shared/src/i18n/ko/native.ts | 8 + shared/src/i18n/nl/native.ts | 8 + shared/src/i18n/pl/native.ts | 8 + shared/src/i18n/ru/native.ts | 8 + shared/src/i18n/sk/native.ts | 8 + shared/src/i18n/sv/native.ts | 8 + shared/src/i18n/th/native.ts | 8 + shared/src/i18n/tr/native.ts | 8 + shared/src/i18n/uk/native.ts | 8 + shared/src/i18n/vi/native.ts | 8 + shared/src/i18n/zh-TW/native.ts | 8 + shared/src/i18n/zh/native.ts | 8 + 40 files changed, 617 insertions(+), 59 deletions(-) diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java b/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java index e22bd4450..26b9a171d 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java @@ -83,6 +83,12 @@ final class TrekSharedFiles { file.put("mimeType", meta.optString("mimeType", "application/octet-stream")); file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP)); if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId")); + // A page or a link: where it came from, so the UI can keep it among the trip's links. + String url = meta.optString("url", ""); + if (url.startsWith("https://") || url.startsWith("http://")) { + file.put("url", url); + file.put("title", meta.optString("title", url)); + } pending = file; if (listener != null) listener.onFile(file); } diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt b/client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt index 388197056..543149dd8 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/share/ShareHandoff.kt @@ -46,6 +46,7 @@ object ShareHandoff { fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching { val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType) if (tripId != null) meta.put("tripId", tripId) + if (item.url != null) meta.put("url", item.url).put("title", item.title) File(dir(context), "pending.data").writeBytes(item.data) File(dir(context), "pending.json").writeText(meta.toString()) true diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt b/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt index 279dcffc7..fe4aa1718 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt @@ -26,6 +26,11 @@ data class ShareItem( val title: String, /** A host, for a page. */ val source: String? = null, + /** + * The web address it came from, when it is a page or a link: the app offers to + * keep it among the trip's links as well as to read a booking out of it. + */ + val url: String? = null, ) { enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE } @@ -90,11 +95,16 @@ object ShareLoader { return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name })) } - /** A link becomes the page it points at; anything else, a text file the import reads. */ + /** + * A link becomes the page it points at, or the bare link when the page cannot be + * read (a sign-in wall, an app-only page, no network), which the app can still + * keep among the trip's links. Anything else, a text file the import reads. + */ internal fun fromText(text: String, subject: String?): ShareItem { val link = firstLink(text) if (link != null) { runCatching { return page(link, subject) } + return linkOnly(link, subject) } val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80))) @@ -121,12 +131,18 @@ object ShareLoader { val html = String(data, Charsets.UTF_8) val host = runCatching { Uri.parse(link).host }.getOrNull() val title = pageTitle(html) ?: subject ?: host ?: "Page" - return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host)) + return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link)) } finally { connection.disconnect() } } + /** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */ + internal fun linkOnly(link: String, subject: String?): ShareItem { + val host = runCatching { Uri.parse(link).host }.getOrNull() + return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link) + } + /** The kind and the MIME type the import should see, from the name when the type is vague. */ internal fun classify(name: String, mime: String): Pair { val ext = name.substringAfterLast('.', "").lowercase() diff --git a/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt b/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt index d2bfebf56..929e482de 100644 --- a/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt +++ b/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt @@ -49,6 +49,16 @@ class NativeLogicTest { assertEquals("page", ShareLoader.slug("!!!")) } + @Test + fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() { + val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null) + assertEquals("text/uri-list", item.mimeType) + assertEquals("https://www.instagram.com/p/abc", item.url) + assertEquals("https://www.instagram.com/p/abc", String(item.data)) + assertEquals("www.instagram.com", item.title) + assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title) + } + @Test fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() { val today = LocalDate.of(2026, 11, 7) diff --git a/client/ios/App/App/SceneDelegate.swift b/client/ios/App/App/SceneDelegate.swift index f8c5f7479..2c69a8254 100644 --- a/client/ios/App/App/SceneDelegate.swift +++ b/client/ios/App/App/SceneDelegate.swift @@ -110,6 +110,11 @@ enum TrekSharedFiles { "data": data.base64EncodedString(), ] if let tripId = info["tripId"] as? Int { file["tripId"] = tripId } + // A page or a link: where it came from, so the UI can keep it among the trip's links. + if let url = info["url"] as? String, url.hasPrefix("https://") || url.hasPrefix("http://") { + file["url"] = url + if let title = info["title"] as? String { file["title"] = title } + } pending = file onReceive?(file) } diff --git a/client/ios/App/ShareExtension/ShareHandoff.swift b/client/ios/App/ShareExtension/ShareHandoff.swift index 0eaf6db2c..6136b6cc9 100644 --- a/client/ios/App/ShareExtension/ShareHandoff.swift +++ b/client/ios/App/ShareExtension/ShareHandoff.swift @@ -54,6 +54,10 @@ enum ShareHandoff { static func store(_ item: ShareItem, tripId: Int?) -> Bool { var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType] if let tripId { meta["tripId"] = tripId } + if let url = item.url { + meta["url"] = url + meta["title"] = item.title + } guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false } return write(dataAccount, item.data) && write(metaAccount, metaData) } diff --git a/client/ios/App/ShareExtension/ShareItem.swift b/client/ios/App/ShareExtension/ShareItem.swift index bdea45ca2..f386161d7 100644 --- a/client/ios/App/ShareExtension/ShareItem.swift +++ b/client/ios/App/ShareExtension/ShareItem.swift @@ -32,6 +32,9 @@ struct ShareItem { var title: String /// A host, for a page. var source: String? + /// The web address it came from, when it is a page or a link: the app offers to + /// keep it among the trip's links as well as to read a booking out of it. + var url: String? = nil var sizeLabel: String { ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file) @@ -88,6 +91,10 @@ enum ShareLoader { let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? "" let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw ShareLoadError.unsupported } + // A text that is just a link (what many apps share) is that link. + if !trimmed.contains(where: \.isWhitespace), let url = URL(string: trimmed), webAddress(url) != nil { + return try await page(at: url) + } let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain", data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil)) @@ -133,21 +140,39 @@ enum ShareLoader { let url = (results["url"] as? String).flatMap(URL.init(string:)) let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page" return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html", - data: Data(html.utf8), title: title, source: url?.host)) + data: Data(html.utf8), title: title, source: url?.host, + url: url.flatMap(webAddress))) } - /// A link shared from an app other than Safari: the page as the server sends it. + /// A link shared from an app other than Safari: the page as the server sends it, + /// or the bare link when the page cannot be read (a sign-in wall, an app-only + /// page, no network), which the app can still keep among the trip's links. private static func page(at url: URL) async throws -> ShareItem { - guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported } + guard let address = webAddress(url) else { throw ShareLoadError.unsupported } var request = URLRequest(url: url, timeoutInterval: 10) request.setValue("text/html", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), - (http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported } - let html = String(decoding: data, as: UTF8.self) - let title = pageTitle(html) ?? url.host ?? "Page" - return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html", - data: data, title: title, source: url.host)) + if let fetched = try? await URLSession.shared.data(for: request), + let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode), + (http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes { + let data = fetched.0 + let html = String(decoding: data, as: UTF8.self) + let title = pageTitle(html) ?? url.host ?? "Page" + return ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html", + data: data, title: title, source: url.host, url: address) + } + return link(url, address) + } + + /// The link alone, as a text/uri-list the app recognises as nothing to read a booking from. + private static func link(_ url: URL, _ address: String) -> ShareItem { + ShareItem(kind: .page, name: "link.url", mimeType: "text/uri-list", data: Data(address.utf8), + title: url.host ?? address, source: url.host, url: address) + } + + /// An http(s) address, the only kind a trip's links take. + private static func webAddress(_ url: URL) -> String? { + guard url.scheme == "https" || url.scheme == "http", url.host != nil else { return nil } + return url.absoluteString } private static func fromFile(_ url: URL) throws -> ShareItem { diff --git a/client/src/mobile/components/NativeImportSheet.test.tsx b/client/src/mobile/components/NativeImportSheet.test.tsx index 63395e961..5cafcd0a5 100644 --- a/client/src/mobile/components/NativeImportSheet.test.tsx +++ b/client/src/mobile/components/NativeImportSheet.test.tsx @@ -1,10 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen } from '../../../tests/helpers/render'; -import { useSharedImportStore } from '../../native/sharedFiles'; +import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; +import { saveSharedLink, useSharedImportStore } from '../../native/sharedFiles'; import { tripRepo } from '../../repo/tripRepo'; +import { useAddonStore } from '../../store/addonStore'; import NativeImportSheet from './NativeImportSheet'; const navigate = vi.hoisted(() => vi.fn()); +vi.mock('../../native/sharedFiles', async (importOriginal) => ({ + ...(await importOriginal()), + saveSharedLink: vi.fn(), +})); vi.mock('react-router', async (importOriginal) => ({ ...(await importOriginal()), useNavigate: () => navigate, @@ -14,7 +19,9 @@ beforeEach(() => { // A second spyOn returns the first spy with its calls; start every test clean. vi.restoreAllMocks(); navigate.mockReset(); - useSharedImportStore.setState({ file: null, tripId: null }); + useSharedImportStore.setState({ file: null, tripId: null, link: null }); + useAddonStore.setState({ addons: [{ id: 'collab', enabled: true }] as never, loaded: true }); + vi.mocked(saveSharedLink).mockReset().mockResolvedValue(); vi.spyOn(tripRepo, 'list').mockResolvedValue({ trips: [ { id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05' }, @@ -24,6 +31,14 @@ beforeEach(() => { } as unknown as Awaited>); }); +const page = () => new File([''], 'hotel.html', { type: 'text/html' }); +const bareLink = () => new File(['https://hotel.example/stay'], 'link.url', { type: 'text/uri-list' }); +const link = (presetTripId: number | null = null) => ({ + url: 'https://hotel.example/stay', + title: 'Hotel Kanra', + presetTripId, +}); + describe('NativeImportSheet', () => { it('asks which trip a shared document goes to, upcoming trips first, and opens it there', async () => { render(); @@ -49,6 +64,58 @@ describe('NativeImportSheet', () => { expect(navigate).toHaveBeenCalledTimes(1); }); + it('asks what a shared page is for, and keeps it among the links of the trip picked', async () => { + render(); + act(() => useSharedImportStore.getState().setFile(page(), link())); + expect(screen.getByText('native.share.linkTitle')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Japan/ })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /native\.share\.saveLink/ })); + expect(screen.getByText('native.share.linkTrip')).toBeInTheDocument(); + fireEvent.click(await screen.findByRole('button', { name: /Japan/ })); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/trips/2?tab=collab')); + expect(saveSharedLink).toHaveBeenCalledWith(2, link()); + expect(useSharedImportStore.getState().file).toBeNull(); + }); + + it('reads a booking from the page when that is asked for', async () => { + render(); + act(() => useSharedImportStore.getState().setFile(page(), link())); + fireEvent.click(screen.getByRole('button', { name: /native\.share\.readBooking/ })); + fireEvent.click(await screen.findByRole('button', { name: /Japan/ })); + expect(useSharedImportStore.getState().tripId).toBe(2); + expect(navigate).toHaveBeenCalledWith('/trips/2'); + expect(saveSharedLink).not.toHaveBeenCalled(); + }); + + it('keeps a bare link in the trip the share sheet picked, with nothing to ask', async () => { + render(); + act(() => useSharedImportStore.getState().setFile(bareLink(), link(2))); + await waitFor(() => expect(saveSharedLink).toHaveBeenCalledWith(2, link(2))); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/trips/2?tab=collab')); + }); + + it('with Collab off, a page is read as a booking and a bare link is explained', async () => { + useAddonStore.setState({ addons: [], loaded: true }); + render(); + act(() => useSharedImportStore.getState().setFile(page(), link())); + expect(screen.queryByText('native.share.linkTitle')).not.toBeInTheDocument(); + expect(await screen.findAllByRole('button', { name: /Japan|Past trip/ })).toHaveLength(2); + + act(() => useSharedImportStore.getState().setFile(bareLink(), link())); + expect(screen.getByText('native.share.linkUnsupported')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Japan/ })).not.toBeInTheDocument(); + }); + + it('says so when the link cannot be saved, and offers the trips again', async () => { + vi.mocked(saveSharedLink).mockRejectedValue(new Error('offline')); + render(); + act(() => useSharedImportStore.getState().setFile(bareLink(), link(2))); + expect(await screen.findByRole('button', { name: /Japan/ })).toBeEnabled(); + expect(navigate).not.toHaveBeenCalled(); + expect(useSharedImportStore.getState().file).not.toBeNull(); + }); + it('stays closed with nothing shared', () => { render(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); diff --git a/client/src/mobile/components/NativeImportSheet.tsx b/client/src/mobile/components/NativeImportSheet.tsx index 447e5a690..751dbe2e1 100644 --- a/client/src/mobile/components/NativeImportSheet.tsx +++ b/client/src/mobile/components/NativeImportSheet.tsx @@ -1,14 +1,21 @@ /** - * NativeImportSheet: a booking document was shared to TREK (native/sharedFiles.ts), - * so this asks which trip it belongs to and opens that trip's booking import with - * the file already chosen. Trips still ahead come first, the ones that ended after. + * NativeImportSheet: something was shared to TREK (native/sharedFiles.ts), so this + * asks which trip it belongs to and opens that trip's booking import with the file + * already chosen. Trips still ahead come first, the ones that ended after. + * + * A web page or a link is asked about first: kept among the trip's links + * (Collaboration, Links), or read as a booking. A bare link, whose page the phone + * could not read, can only be kept; with Collaboration off, a page can only be read. */ -import { ChevronRight, FileText } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import { ChevronRight, FileText, Link2, ScanText } from 'lucide-react'; +import React, { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router'; +import { useToast } from '../../components/shared/Toast'; import { useTranslation } from '../../i18n'; -import { useSharedImportStore } from '../../native/sharedFiles'; +import { saveSharedLink, useSharedImportStore } from '../../native/sharedFiles'; import { tripRepo } from '../../repo/tripRepo'; +import { useAddonStore } from '../../store/addonStore'; +import { offlineOr } from '../../utils/needsConnection'; import { TileHeader } from '../screens/trip/sheets/MTripSheetUi'; import MSheet from './MSheet'; @@ -19,15 +26,42 @@ interface TripRow { end_date?: string | null; } +type LinkChoice = 'link' | 'booking'; + +/** What to offer for a shared link: the links when Collaboration is on, the import when there is a page to read. */ +function linkChoicesOf(file: File, collabOn: boolean): LinkChoice[] { + const choices: LinkChoice[] = []; + if (collabOn) choices.push('link'); + if (file.type !== 'text/uri-list') choices.push('booking'); + return choices; +} + export default function NativeImportSheet(): React.ReactElement | null { const { t } = useTranslation(); + const toast = useToast(); const navigate = useNavigate(); const { pathname } = useLocation(); const file = useSharedImportStore((s) => s.file); const tripId = useSharedImportStore((s) => s.tripId); + const link = useSharedImportStore((s) => s.link); const chooseTrip = useSharedImportStore((s) => s.chooseTrip); const setFile = useSharedImportStore((s) => s.setFile); + const collabOn = useAddonStore((s) => s.isEnabled('collab')); const [trips, setTrips] = useState([]); + const [picked, setPicked] = useState(null); + const [saving, setSaving] = useState(false); + + // Each shared item is asked about afresh. + useEffect(() => { + setPicked(null); + setSaving(false); + }, [file]); + + const choices = file && link ? linkChoicesOf(file, collabOn) : []; + // With one way to go there is nothing to ask. + const choice: LinkChoice | null = !link ? 'booking' : (picked ?? (choices.length === 1 ? choices[0] : null)); + const asking = !!link && choice == null && choices.length > 1; + const nowhere = !!link && choices.length === 0; const open = !!file && tripId == null; @@ -54,41 +88,129 @@ export default function NativeImportSheet(): React.ReactElement | null { void navigate(`/trips/${tripId}`); }, [file, tripId, pathname, navigate]); - const pick = (id: number) => chooseTrip(id); + const saveLink = async (id: number) => { + if (!link || saving) return; + setSaving(true); + try { + await saveSharedLink(id, link); + toast.success(t('native.share.linkSaved')); + setFile(null); + void navigate(`/trips/${id}?tab=collab`); + } catch { + toast.error(offlineOr(t, t('common.error'))); + setSaving(false); + } + }; + + // The trip the share sheet picked, used as soon as it is clear what the link is for. + // saveLink is a fresh function on every render, so the effect reaches it through a + // ref and runs on the trip and the choice alone. + const preset = link?.presetTripId ?? null; + const saveLinkRef = useRef(saveLink); + saveLinkRef.current = saveLink; + useEffect(() => { + if (preset == null || choice == null || !file) return; + if (choice === 'booking') chooseTrip(preset); + else void saveLinkRef.current(preset); + }, [preset, choice, file, chooseTrip]); + + const pick = (id: number) => (choice === 'link' ? void saveLink(id) : chooseTrip(id)); + // While the picked trip takes the link, there is nothing to choose; should it fail, the trips come back. + const showTrips = !asking && !nowhere && !(preset != null && choice === 'link' && saving); return ( setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
} - title={t('native.share.title')} - sub={file?.name} + icon={link ? : } + title={t( + asking ? 'native.share.linkTitle' : choice === 'link' ? 'native.share.linkTrip' : 'native.share.title' + )} + sub={link ? link.title : file?.name} onClose={() => setFile(null)} closeLabel={t('common.close')} />
-
- {trips.map((trip) => ( - - ))} - {trips.length === 0 && ( -

- {t('native.share.noTrips')} -

- )} -
+ {asking && ( +
+ } + label={t('native.share.saveLink')} + hint={t('native.share.saveLinkHint')} + onClick={() => setPicked('link')} + /> + } + label={t('native.share.readBooking')} + hint={t('native.share.readBookingHint')} + onClick={() => setPicked('booking')} + /> +
+ )} + {nowhere && ( +

+ {t('native.share.linkUnsupported')} +

+ )} + {showTrips && ( +
+ {trips.map((trip) => ( + + ))} + {trips.length === 0 && ( +

+ {t('native.share.noTrips')} +

+ )} +
+ )}
); } + +function ChoiceRow({ + icon, + label, + hint, + onClick, +}: { + icon: React.ReactNode; + label: string; + hint: string; + onClick: () => void; +}): React.ReactElement { + return ( + + ); +} diff --git a/client/src/native/sharedFiles.test.ts b/client/src/native/sharedFiles.test.ts index d1ba69ad7..64e268f45 100644 --- a/client/src/native/sharedFiles.test.ts +++ b/client/src/native/sharedFiles.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { hasNativeFeature } from './capabilities'; -import { startSharedFiles, toFile, useSharedImportStore } from './sharedFiles'; +import { linkOf, startSharedFiles, toFile, useSharedImportStore } from './sharedFiles'; import { TrekShell } from './trekShell'; vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn(), loadNativeCapabilities: vi.fn(async () => ({})) })); @@ -12,7 +12,7 @@ vi.mock('./trekShell', () => ({ const PDF = { name: 'booking.pdf', mimeType: 'application/pdf', data: btoa('%PDF-1.4 hello') }; beforeEach(() => { - useSharedImportStore.setState({ file: null, tripId: null }); + useSharedImportStore.setState({ file: null, tripId: null, link: null }); vi.mocked(hasNativeFeature).mockReturnValue(true); vi.mocked(TrekShell.takeSharedFile).mockReset().mockResolvedValue({ file: null }); }); @@ -50,6 +50,36 @@ describe('shared booking documents', () => { expect(useSharedImportStore.getState().file?.name).toBe('booking.pdf'); }); + it('keeps the address of a shared page, and holds the picked trip back until the link is decided on', async () => { + const page = { name: 'hotel.html', mimeType: 'text/html', data: btoa('') }; + vi.mocked(TrekShell.takeSharedFile).mockResolvedValue({ + file: { ...page, tripId: 12, url: 'https://hotel.example/stay', title: 'Hotel Kanra' }, + }); + await startSharedFiles(); + expect(useSharedImportStore.getState()).toMatchObject({ + tripId: null, + link: { url: 'https://hotel.example/stay', title: 'Hotel Kanra', presetTripId: 12 }, + }); + }); + + it('takes http(s) links only, titled by the host when the page has none', () => { + const base = { name: 'link.url', mimeType: 'text/uri-list', data: '' }; + expect(linkOf({ ...base, url: 'https://maps.app.goo.gl/x' })).toEqual({ + url: 'https://maps.app.goo.gl/x', + title: 'maps.app.goo.gl', + presetTripId: null, + }); + expect(linkOf({ ...base, url: 'javascript:alert(1)' })).toBeNull(); + expect(linkOf({ ...base, url: 'not a url' })).toBeNull(); + expect(linkOf(base)).toBeNull(); + }); + + it('forgets the link along with the file', () => { + useSharedImportStore.getState().setFile(toFile(PDF), { url: 'https://a.example/', title: 'A', presetTripId: null }); + useSharedImportStore.getState().setFile(null, { url: 'https://a.example/', title: 'A', presetTripId: null }); + expect(useSharedImportStore.getState().link).toBeNull(); + }); + it('does nothing in a binary without the feature', async () => { vi.mocked(hasNativeFeature).mockReturnValue(false); vi.mocked(TrekShell.addListener).mockClear(); diff --git a/client/src/native/sharedFiles.ts b/client/src/native/sharedFiles.ts index e7065bd57..4e5faf701 100644 --- a/client/src/native/sharedFiles.ts +++ b/client/src/native/sharedFiles.ts @@ -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((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 { + 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 { await loadNativeCapabilities(); @@ -46,9 +88,11 @@ export async function startSharedFiles(): Promise { 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 { diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 5137d8667..30a1c2db5 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -163,6 +163,10 @@ export interface SharedFilePayload { data: string; /** Set when the share sheet already asked which trip it belongs to (feature shareSheet). */ tripId?: number; + /** The http(s) address of a shared page or link, which can go to the trip's links instead. */ + url?: string; + /** The page's title, for that link. */ + title?: string; } /** diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index b3026e1b9..1fa95ccf5 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -98,6 +98,14 @@ const native: TranslationStrings = { 'native.lock.settingHint': 'يطلب ذلك عند فتح TREK وبعد نصف دقيقة في الخلفية، ويخفي التطبيق في مبدّل التطبيقات.', 'native.share.title': 'الاستيراد إلى أي رحلة؟', 'native.share.noTrips': 'لا توجد رحلة بعد. أنشئ رحلة أولًا ثم شارك المستند مجددًا.', + 'native.share.linkTitle': 'ماذا يفعل TREK بهذا الرابط؟', + 'native.share.saveLink': 'حفظ في روابط الرحلة', + 'native.share.saveLinkHint': 'يجده الجميع في الرحلة ضمن الروابط', + 'native.share.readBooking': 'قراءة حجز منه', + 'native.share.readBookingHint': 'لصفحة تأكيد', + 'native.share.linkTrip': 'في أي رحلة يُحفظ الرابط؟', + 'native.share.linkSaved': 'تم حفظ الرابط', + 'native.share.linkUnsupported': 'فعّل Collab لحفظ الروابط في رحلة.', 'native.widget.dayOf': 'اليوم {n} من {m}', 'native.widget.today': 'اليوم', 'native.widget.noTrip': 'لا توجد رحلة مخططة بعد', diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index 060957862..a897d20d1 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -102,6 +102,14 @@ const native: TranslationStrings = { 'TREK açılanda və arxa fonda yarım dəqiqədən sonra soruşur, tətbiqi tətbiq dəyişdiricisində gizlədir.', 'native.share.title': 'Hansı səyahətə idxal edilsin?', 'native.share.noTrips': 'Hələ səyahət yoxdur. Əvvəlcə birini yaradın, sonra sənədi yenidən paylaşın.', + 'native.share.linkTitle': 'TREK bu keçidlə nə etsin?', + 'native.share.saveLink': 'Səyahətin keçidlərinə yadda saxla', + 'native.share.saveLinkHint': 'Səyahətdəki hər kəs onu Keçidlər bölməsində tapır', + 'native.share.readBooking': 'Ondan rezervasiya oxu', + 'native.share.readBookingHint': 'Təsdiq səhifəsi üçün', + 'native.share.linkTrip': 'Keçid hansı səyahətə yadda saxlanılsın?', + 'native.share.linkSaved': 'Keçid yadda saxlanıldı', + 'native.share.linkUnsupported': 'Səyahətdə keçidləri saxlamaq üçün Collab funksiyasını aç.', 'native.widget.dayOf': '{m} gündən {n}-ci gün', 'native.widget.today': 'Bu gün', 'native.widget.noTrip': 'Hələ planlanmış səyahət yoxdur', diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index 4ed08e7a5..2343e09f7 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -101,6 +101,14 @@ const native: TranslationStrings = { 'Pede ao abrir o TREK e após meio minuto em segundo plano, e oculta o app no seletor de apps.', 'native.share.title': 'Importar para qual viagem?', 'native.share.noTrips': 'Ainda não há viagens. Crie uma primeiro e compartilhe o documento de novo.', + 'native.share.linkTitle': 'O que o TREK deve fazer com este link?', + 'native.share.saveLink': 'Salvar nos links da viagem', + 'native.share.saveLinkHint': 'Todos na viagem o encontram em Links', + 'native.share.readBooking': 'Ler uma reserva dele', + 'native.share.readBookingHint': 'Para uma página de confirmação', + 'native.share.linkTrip': 'Salvar o link em qual viagem?', + 'native.share.linkSaved': 'Link salvo', + 'native.share.linkUnsupported': 'Ative o Collab para guardar links em uma viagem.', 'native.widget.dayOf': 'Dia {n} de {m}', 'native.widget.today': 'Hoje', 'native.widget.noTrip': 'Nenhuma viagem planejada ainda', diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index 977e47ecb..c17ba4414 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -102,6 +102,14 @@ const native: TranslationStrings = { 'Ho demana en obrir TREK i després de mig minut en segon pla, i amaga l’app al selector d’apps.', 'native.share.title': 'A quin viatge vols importar-ho?', 'native.share.noTrips': 'Encara no hi ha cap viatge. Crea’n un i torna a compartir el document.', + 'native.share.linkTitle': 'Què ha de fer TREK amb aquest enllaç?', + 'native.share.saveLink': 'Desa als enllaços del viatge', + 'native.share.saveLinkHint': 'Tothom al viatge el troba a Enllaços', + 'native.share.readBooking': 'Llegeix-ne una reserva', + 'native.share.readBookingHint': 'Per a una pàgina de confirmació', + 'native.share.linkTrip': 'A quin viatge desar l\'enllaç?', + 'native.share.linkSaved': 'Enllaç desat', + 'native.share.linkUnsupported': 'Activa Collab per guardar enllaços en un viatge.', 'native.widget.dayOf': 'Dia {n} de {m}', 'native.widget.today': 'Avui', 'native.widget.noTrip': 'Encara no hi ha cap viatge planificat', diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index ab179b87b..2d34100dc 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -100,6 +100,14 @@ const native: TranslationStrings = { 'Zeptá se při otevření TREK a po půl minutě na pozadí a skryje aplikaci v přepínači aplikací.', 'native.share.title': 'Importovat do které cesty?', 'native.share.noTrips': 'Zatím žádná cesta. Nejdřív nějakou vytvořte a pak dokument sdílejte znovu.', + 'native.share.linkTitle': 'Co má TREK s tímto odkazem udělat?', + 'native.share.saveLink': 'Uložit do odkazů cesty', + 'native.share.saveLinkHint': 'Všichni v cestě ho najdou v Odkazech', + 'native.share.readBooking': 'Přečíst z něj rezervaci', + 'native.share.readBookingHint': 'Pro potvrzovací stránku', + 'native.share.linkTrip': 'Do které cesty odkaz uložit?', + 'native.share.linkSaved': 'Odkaz uložen', + 'native.share.linkUnsupported': 'Zapni Collab, abys mohl ukládat odkazy do cesty.', 'native.widget.dayOf': 'Den {n} z {m}', 'native.widget.today': 'Dnes', 'native.widget.noTrip': 'Zatím žádná naplánovaná cesta', diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index dfc1ef242..65e70615c 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -103,6 +103,14 @@ const native: TranslationStrings = { 'Fragt beim Öffnen und nach einer halben Minute im Hintergrund nach und verbirgt TREK im App-Umschalter.', 'native.share.title': 'In welchen Trip importieren?', 'native.share.noTrips': 'Noch kein Trip. Lege zuerst einen an und teile das Dokument dann erneut.', + 'native.share.linkTitle': 'Was soll TREK mit dem Link machen?', + 'native.share.saveLink': 'Bei den Links des Trips speichern', + 'native.share.saveLinkHint': 'Alle im Trip finden ihn unter Links', + 'native.share.readBooking': 'Buchung daraus lesen', + 'native.share.readBookingHint': 'Für eine Bestätigungsseite', + 'native.share.linkTrip': 'In welchem Trip den Link speichern?', + 'native.share.linkSaved': 'Link gespeichert', + 'native.share.linkUnsupported': 'Schalte Collab ein, um Links in einem Trip zu sammeln.', 'native.widget.dayOf': 'Tag {n} von {m}', 'native.widget.today': 'Heute', 'native.widget.noTrip': 'Noch keine Reise geplant', diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index 042ee7075..7620c82ff 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -101,6 +101,14 @@ const native: TranslationStrings = { 'Asks when TREK opens and after half a minute in the background, and hides the app in the app switcher.', 'native.share.title': 'Import into which trip?', 'native.share.noTrips': 'No trip yet. Create one first, then share the document again.', + 'native.share.linkTitle': 'What should TREK do with this link?', + 'native.share.saveLink': 'Save to the trip\'s links', + 'native.share.saveLinkHint': 'Everyone on the trip finds it under Links', + 'native.share.readBooking': 'Read a booking from it', + 'native.share.readBookingHint': 'For a confirmation page', + 'native.share.linkTrip': 'Save the link to which trip?', + 'native.share.linkSaved': 'Link saved', + 'native.share.linkUnsupported': 'Turn on Collab to keep links in a trip.', 'native.widget.dayOf': 'Day {n} of {m}', 'native.widget.today': 'Today', 'native.widget.noTrip': 'No trip planned yet', diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index de341144f..ba5ab4b5a 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -103,6 +103,14 @@ const native: TranslationStrings = { 'Lo pide al abrir TREK y tras medio minuto en segundo plano, y oculta la app en el selector de apps.', 'native.share.title': '¿En qué viaje importar?', 'native.share.noTrips': 'Aún no hay viajes. Crea uno primero y vuelve a compartir el documento.', + 'native.share.linkTitle': '¿Qué debe hacer TREK con este enlace?', + 'native.share.saveLink': 'Guardar en los enlaces del viaje', + 'native.share.saveLinkHint': 'Todos en el viaje lo encuentran en Enlaces', + 'native.share.readBooking': 'Leer una reserva de él', + 'native.share.readBookingHint': 'Para una página de confirmación', + 'native.share.linkTrip': '¿En qué viaje guardar el enlace?', + 'native.share.linkSaved': 'Enlace guardado', + 'native.share.linkUnsupported': 'Activa Collab para guardar enlaces en un viaje.', 'native.widget.dayOf': 'Día {n} de {m}', 'native.widget.today': 'Hoy', 'native.widget.noTrip': 'Aún no hay ningún viaje planeado', diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index b369ea1a0..b3f180d46 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -101,6 +101,14 @@ const native: TranslationStrings = { 'Küsib TREK-i avamisel ja pärast poolt minutit taustal ning peidab rakenduse rakenduste vahetajas.', 'native.share.title': 'Millisesse reisi importida?', 'native.share.noTrips': 'Reise veel pole. Loo esmalt üks ja jaga dokumenti uuesti.', + 'native.share.linkTitle': 'Mida peaks TREK selle lingiga tegema?', + 'native.share.saveLink': 'Salvesta reisi linkide hulka', + 'native.share.saveLinkHint': 'Kõik reisil olijad leiavad selle jaotisest Lingid', + 'native.share.readBooking': 'Loe sellest broneering', + 'native.share.readBookingHint': 'Kinnituslehe jaoks', + 'native.share.linkTrip': 'Millisesse reisi link salvestada?', + 'native.share.linkSaved': 'Link salvestatud', + 'native.share.linkUnsupported': 'Lülita Collab sisse, et reisis linke hoida.', 'native.widget.dayOf': 'Päev {n}/{m}', 'native.widget.today': 'Täna', 'native.widget.noTrip': 'Reisi pole veel planeeritud', diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index f0c10370c..d9cb9816c 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -103,6 +103,14 @@ const native: TranslationStrings = { 'Le demande à l’ouverture de TREK et après une demi-minute en arrière-plan, et masque l’app dans le sélecteur d’apps.', 'native.share.title': 'Importer dans quel voyage ?', 'native.share.noTrips': 'Pas encore de voyage. Créez-en un, puis partagez à nouveau le document.', + 'native.share.linkTitle': 'Que doit faire TREK de ce lien ?', + 'native.share.saveLink': 'Enregistrer dans les liens du voyage', + 'native.share.saveLinkHint': 'Tous les participants le trouvent sous Liens', + 'native.share.readBooking': 'En extraire une réservation', + 'native.share.readBookingHint': 'Pour une page de confirmation', + 'native.share.linkTrip': 'Enregistrer le lien dans quel voyage ?', + 'native.share.linkSaved': 'Lien enregistré', + 'native.share.linkUnsupported': 'Activez Collab pour garder des liens dans un voyage.', 'native.widget.dayOf': 'Jour {n} sur {m}', 'native.widget.today': 'Aujourd’hui', 'native.widget.noTrip': 'Aucun voyage prévu pour l’instant', diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index c54f00e5e..6176988d6 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -104,6 +104,14 @@ const native: TranslationStrings = { 'Ζητείται κατά το άνοιγμα του TREK και μετά από μισό λεπτό στο παρασκήνιο, και η εφαρμογή κρύβεται στην εναλλαγή εφαρμογών.', 'native.share.title': 'Εισαγωγή σε ποιο ταξίδι;', 'native.share.noTrips': 'Δεν υπάρχει ακόμα ταξίδι. Δημιουργήστε ένα πρώτα και κοινοποιήστε ξανά το έγγραφο.', + 'native.share.linkTitle': 'Τι να κάνει το TREK με αυτόν τον σύνδεσμο;', + 'native.share.saveLink': 'Αποθήκευση στους συνδέσμους του ταξιδιού', + 'native.share.saveLinkHint': 'Όλοι στο ταξίδι τον βρίσκουν στους Συνδέσμους', + 'native.share.readBooking': 'Ανάγνωση κράτησης από αυτόν', + 'native.share.readBookingHint': 'Για σελίδα επιβεβαίωσης', + 'native.share.linkTrip': 'Σε ποιο ταξίδι να αποθηκευτεί ο σύνδεσμος;', + 'native.share.linkSaved': 'Ο σύνδεσμος αποθηκεύτηκε', + 'native.share.linkUnsupported': 'Ενεργοποίησε το Collab για να κρατάς συνδέσμους σε ένα ταξίδι.', 'native.widget.dayOf': 'Ημέρα {n} από {m}', 'native.widget.today': 'Σήμερα', 'native.widget.noTrip': 'Δεν υπάρχει ακόμα προγραμματισμένο ταξίδι', diff --git a/shared/src/i18n/he/native.ts b/shared/src/i18n/he/native.ts index e821f853f..e22721241 100644 --- a/shared/src/i18n/he/native.ts +++ b/shared/src/i18n/he/native.ts @@ -98,6 +98,14 @@ const native: TranslationStrings = { 'native.lock.settingHint': 'מבקש בעת פתיחת TREK ואחרי חצי דקה ברקע, ומסתיר את האפליקציה במחליף האפליקציות.', 'native.share.title': 'לייבא לאיזה טיול?', 'native.share.noTrips': 'אין עדיין טיול. צרו טיול ואז שתפו את המסמך שוב.', + 'native.share.linkTitle': 'מה TREK צריך לעשות עם הקישור הזה?', + 'native.share.saveLink': 'שמירה בקישורי הטיול', + 'native.share.saveLinkHint': 'כל המשתתפים בטיול ימצאו אותו תחת קישורים', + 'native.share.readBooking': 'קריאת הזמנה ממנו', + 'native.share.readBookingHint': 'לדף אישור', + 'native.share.linkTrip': 'לאיזה טיול לשמור את הקישור?', + 'native.share.linkSaved': 'הקישור נשמר', + 'native.share.linkUnsupported': 'הפעל את Collab כדי לשמור קישורים בטיול.', 'native.widget.dayOf': 'יום {n} מתוך {m}', 'native.widget.today': 'היום', 'native.widget.noTrip': 'עדיין לא תוכנן טיול', diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 01e91e1da..511ea6aca 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -103,6 +103,14 @@ const native: TranslationStrings = { 'Rákérdez a TREK megnyitásakor és fél perc háttérben töltött idő után, és elrejti az alkalmazást az alkalmazásváltóban.', 'native.share.title': 'Melyik utazásba importáljuk?', 'native.share.noTrips': 'Még nincs utazás. Előbb hozz létre egyet, aztán oszd meg újra a dokumentumot.', + 'native.share.linkTitle': 'Mit kezdjen a TREK ezzel a linkkel?', + 'native.share.saveLink': 'Mentés az utazás linkjei közé', + 'native.share.saveLinkHint': 'Az utazás minden tagja megtalálja a Linkek között', + 'native.share.readBooking': 'Foglalás kiolvasása belőle', + 'native.share.readBookingHint': 'Visszaigazoló oldalhoz', + 'native.share.linkTrip': 'Melyik utazásba mentsem a linket?', + 'native.share.linkSaved': 'Link mentve', + 'native.share.linkUnsupported': 'Kapcsold be a Collabot, hogy linkeket tarts egy utazásban.', 'native.widget.dayOf': '{m}/{n}. nap', 'native.widget.today': 'Ma', 'native.widget.noTrip': 'Még nincs tervezett utazás', diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index 66bda85cd..a0e696013 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -102,6 +102,14 @@ const native: TranslationStrings = { 'Meminta saat TREK dibuka dan setelah setengah menit di latar belakang, serta menyembunyikan aplikasi di pengalih aplikasi.', 'native.share.title': 'Impor ke perjalanan mana?', 'native.share.noTrips': 'Belum ada perjalanan. Buat satu dulu, lalu bagikan dokumennya lagi.', + 'native.share.linkTitle': 'Apa yang harus TREK lakukan dengan tautan ini?', + 'native.share.saveLink': 'Simpan ke tautan perjalanan', + 'native.share.saveLinkHint': 'Semua orang di perjalanan menemukannya di Tautan', + 'native.share.readBooking': 'Baca pemesanan darinya', + 'native.share.readBookingHint': 'Untuk halaman konfirmasi', + 'native.share.linkTrip': 'Simpan tautan ke perjalanan mana?', + 'native.share.linkSaved': 'Tautan disimpan', + 'native.share.linkUnsupported': 'Aktifkan Collab untuk menyimpan tautan dalam perjalanan.', 'native.widget.dayOf': 'Hari {n} dari {m}', 'native.widget.today': 'Hari ini', 'native.widget.noTrip': 'Belum ada perjalanan yang direncanakan', diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index 00360092c..68bebba4d 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -103,6 +103,14 @@ const native: TranslationStrings = { 'Lo chiede all’apertura di TREK e dopo mezzo minuto in background, e nasconde l’app nel selettore delle app.', 'native.share.title': 'Importare in quale viaggio?', 'native.share.noTrips': 'Ancora nessun viaggio. Creane uno, poi condividi di nuovo il documento.', + 'native.share.linkTitle': 'Cosa deve fare TREK con questo link?', + 'native.share.saveLink': 'Salva nei link del viaggio', + 'native.share.saveLinkHint': 'Tutti nel viaggio lo trovano in Link', + 'native.share.readBooking': 'Leggi una prenotazione', + 'native.share.readBookingHint': 'Per una pagina di conferma', + 'native.share.linkTrip': 'In quale viaggio salvare il link?', + 'native.share.linkSaved': 'Link salvato', + 'native.share.linkUnsupported': 'Attiva Collab per conservare i link in un viaggio.', 'native.widget.dayOf': 'Giorno {n} di {m}', 'native.widget.today': 'Oggi', 'native.widget.noTrip': 'Nessun viaggio ancora in programma', diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index 49a467130..1b27b956c 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -102,6 +102,14 @@ const native: TranslationStrings = { 'TREKを開いたときと、バックグラウンドで30秒経過したあとに確認し、アプリスイッチャーでは内容を隠します。', 'native.share.title': 'どの旅行に取り込みますか?', 'native.share.noTrips': 'まだ旅行がありません。先に作成してから、もう一度ドキュメントを共有してください。', + 'native.share.linkTitle': 'このリンクをTREKでどうしますか?', + 'native.share.saveLink': '旅行のリンクに保存', + 'native.share.saveLinkHint': '旅行のメンバー全員がリンクで見られます', + 'native.share.readBooking': '予約を読み取る', + 'native.share.readBookingHint': '確認ページの場合', + 'native.share.linkTrip': 'どの旅行にリンクを保存しますか?', + 'native.share.linkSaved': 'リンクを保存しました', + 'native.share.linkUnsupported': '旅行にリンクを保存するにはCollabをオンにしてください。', 'native.widget.dayOf': '{m}日中{n}日目', 'native.widget.today': '今日', 'native.widget.noTrip': 'まだ予定している旅行はありません', diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index dd7ea1491..6f8cbd89f 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -99,6 +99,14 @@ const native: TranslationStrings = { 'TREK을 열 때와 백그라운드에서 30초가 지난 후에 확인하며, 앱 전환기에서는 내용을 숨깁니다.', 'native.share.title': '어느 여행으로 가져올까요?', 'native.share.noTrips': '아직 여행이 없습니다. 먼저 여행을 만든 뒤 문서를 다시 공유하세요.', + 'native.share.linkTitle': '이 링크로 TREK이 무엇을 할까요?', + 'native.share.saveLink': '여행 링크에 저장', + 'native.share.saveLinkHint': '여행의 모든 사람이 링크에서 볼 수 있어요', + 'native.share.readBooking': '예약 정보 읽기', + 'native.share.readBookingHint': '확인 페이지인 경우', + 'native.share.linkTrip': '어느 여행에 링크를 저장할까요?', + 'native.share.linkSaved': '링크를 저장했어요', + 'native.share.linkUnsupported': '여행에 링크를 보관하려면 Collab을 켜세요.', 'native.widget.dayOf': '{m}일 중 {n}일째', 'native.widget.today': '오늘', 'native.widget.noTrip': '아직 계획된 여행이 없습니다', diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index 90e7c6fed..526f5718b 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -101,6 +101,14 @@ const native: TranslationStrings = { 'Vraagt erom bij het openen van TREK en na een halve minuut op de achtergrond, en verbergt de app in de appkiezer.', 'native.share.title': 'In welke reis importeren?', 'native.share.noTrips': 'Nog geen reis. Maak er eerst een aan en deel het document dan opnieuw.', + 'native.share.linkTitle': 'Wat moet TREK met deze link doen?', + 'native.share.saveLink': 'Opslaan bij de links van de reis', + 'native.share.saveLinkHint': 'Iedereen op de reis vindt hem onder Links', + 'native.share.readBooking': 'Er een boeking uit lezen', + 'native.share.readBookingHint': 'Voor een bevestigingspagina', + 'native.share.linkTrip': 'In welke reis de link opslaan?', + 'native.share.linkSaved': 'Link opgeslagen', + 'native.share.linkUnsupported': 'Zet Collab aan om links in een reis te bewaren.', 'native.widget.dayOf': 'Dag {n} van {m}', 'native.widget.today': 'Vandaag', 'native.widget.noTrip': 'Nog geen reis gepland', diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index 7ea240941..100387f69 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -101,6 +101,14 @@ const native: TranslationStrings = { 'Pyta przy otwieraniu TREK i po pół minuty w tle oraz ukrywa aplikację w przełączniku aplikacji.', 'native.share.title': 'Do której podróży zaimportować?', 'native.share.noTrips': 'Nie ma jeszcze podróży. Najpierw utwórz podróż, a potem udostępnij dokument ponownie.', + 'native.share.linkTitle': 'Co TREK ma zrobić z tym linkiem?', + 'native.share.saveLink': 'Zapisz w linkach podróży', + 'native.share.saveLinkHint': 'Wszyscy w podróży znajdą go w Linkach', + 'native.share.readBooking': 'Odczytaj z niego rezerwację', + 'native.share.readBookingHint': 'Dla strony z potwierdzeniem', + 'native.share.linkTrip': 'Do której podróży zapisać link?', + 'native.share.linkSaved': 'Link zapisany', + 'native.share.linkUnsupported': 'Włącz Collab, aby przechowywać linki w podróży.', 'native.widget.dayOf': 'Dzień {n} z {m}', 'native.widget.today': 'Dziś', 'native.widget.noTrip': 'Nie zaplanowano jeszcze żadnej podróży', diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index 8a70eb8e0..97f837c3b 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -102,6 +102,14 @@ const native: TranslationStrings = { 'Запрашивается при открытии TREK и через полминуты в фоне, а в переключателе приложений содержимое скрыто.', 'native.share.title': 'В какую поездку импортировать?', 'native.share.noTrips': 'Поездок пока нет. Сначала создайте поездку, затем снова поделитесь документом.', + 'native.share.linkTitle': 'Что TREK сделать с этой ссылкой?', + 'native.share.saveLink': 'Сохранить в ссылках поездки', + 'native.share.saveLinkHint': 'Все участники поездки найдут её в разделе «Ссылки»', + 'native.share.readBooking': 'Прочитать из неё бронирование', + 'native.share.readBookingHint': 'Для страницы подтверждения', + 'native.share.linkTrip': 'В какую поездку сохранить ссылку?', + 'native.share.linkSaved': 'Ссылка сохранена', + 'native.share.linkUnsupported': 'Включите Collab, чтобы хранить ссылки в поездке.', 'native.widget.dayOf': 'День {n} из {m}', 'native.widget.today': 'Сегодня', 'native.widget.noTrip': 'Поездок пока не запланировано', diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index bec17f293..62312e411 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -100,6 +100,14 @@ const native: TranslationStrings = { 'Opýta sa pri otvorení TREK a po pol minúte na pozadí a skryje aplikáciu v prepínači aplikácií.', 'native.share.title': 'Importovať do ktorej cesty?', 'native.share.noTrips': 'Zatiaľ žiadna cesta. Najprv nejakú vytvorte a potom dokument zdieľajte znova.', + 'native.share.linkTitle': 'Čo má TREK s týmto odkazom urobiť?', + 'native.share.saveLink': 'Uložiť do odkazov cesty', + 'native.share.saveLinkHint': 'Všetci v ceste ho nájdu v Odkazoch', + 'native.share.readBooking': 'Prečítať z neho rezerváciu', + 'native.share.readBookingHint': 'Pre potvrdzovaciu stránku', + 'native.share.linkTrip': 'Do ktorej cesty odkaz uložiť?', + 'native.share.linkSaved': 'Odkaz uložený', + 'native.share.linkUnsupported': 'Zapni Collab, aby si mohol ukladať odkazy do cesty.', 'native.widget.dayOf': 'Deň {n} z {m}', 'native.widget.today': 'Dnes', 'native.widget.noTrip': 'Zatiaľ žiadna naplánovaná cesta', diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index 126712007..ebf3728a6 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -101,6 +101,14 @@ const native: TranslationStrings = { 'Frågar när TREK öppnas och efter en halv minut i bakgrunden, och döljer appen i appväxlaren.', 'native.share.title': 'Importera till vilken resa?', 'native.share.noTrips': 'Ingen resa ännu. Skapa en först och dela sedan dokumentet igen.', + 'native.share.linkTitle': 'Vad ska TREK göra med länken?', + 'native.share.saveLink': 'Spara bland resans länkar', + 'native.share.saveLinkHint': 'Alla på resan hittar den under Länkar', + 'native.share.readBooking': 'Läs en bokning ur den', + 'native.share.readBookingHint': 'För en bekräftelsesida', + 'native.share.linkTrip': 'Spara länken i vilken resa?', + 'native.share.linkSaved': 'Länk sparad', + 'native.share.linkUnsupported': 'Slå på Collab för att spara länkar i en resa.', 'native.widget.dayOf': 'Dag {n} av {m}', 'native.widget.today': 'I dag', 'native.widget.noTrip': 'Ingen resa planerad ännu', diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index 2f73b7fcb..6dd32b4d1 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -98,6 +98,14 @@ const native: TranslationStrings = { 'native.lock.settingHint': 'ถามเมื่อเปิด TREK และหลังจากอยู่เบื้องหลังครึ่งนาที และซ่อนแอปในตัวสลับแอป', 'native.share.title': 'นำเข้าไปยังทริปไหน', 'native.share.noTrips': 'ยังไม่มีทริป สร้างทริปก่อนแล้วแชร์เอกสารอีกครั้ง', + 'native.share.linkTitle': 'ให้ TREK ทำอะไรกับลิงก์นี้?', + 'native.share.saveLink': 'บันทึกในลิงก์ของทริป', + 'native.share.saveLinkHint': 'ทุกคนในทริปจะพบได้ในส่วนลิงก์', + 'native.share.readBooking': 'อ่านการจองจากลิงก์นี้', + 'native.share.readBookingHint': 'สำหรับหน้ายืนยัน', + 'native.share.linkTrip': 'บันทึกลิงก์ลงในทริปไหน?', + 'native.share.linkSaved': 'บันทึกลิงก์แล้ว', + 'native.share.linkUnsupported': 'เปิด Collab เพื่อเก็บลิงก์ในทริป', 'native.widget.dayOf': 'วันที่ {n} จาก {m}', 'native.widget.today': 'วันนี้', 'native.widget.noTrip': 'ยังไม่มีทริปที่วางแผนไว้', diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index affbed12e..55c254930 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -102,6 +102,14 @@ const native: TranslationStrings = { 'TREK açılırken ve arka planda yarım dakika sonra sorar, uygulamayı uygulama değiştiricide gizler.', 'native.share.title': 'Hangi geziye aktarılsın?', 'native.share.noTrips': 'Henüz gezi yok. Önce bir gezi oluşturun, sonra belgeyi yeniden paylaşın.', + 'native.share.linkTitle': 'TREK bu bağlantıyla ne yapsın?', + 'native.share.saveLink': 'Seyahatin bağlantılarına kaydet', + 'native.share.saveLinkHint': 'Seyahatteki herkes onu Bağlantılar altında bulur', + 'native.share.readBooking': 'Ondan bir rezervasyon oku', + 'native.share.readBookingHint': 'Bir onay sayfası için', + 'native.share.linkTrip': 'Bağlantı hangi seyahate kaydedilsin?', + 'native.share.linkSaved': 'Bağlantı kaydedildi', + 'native.share.linkUnsupported': 'Bir seyahatte bağlantı tutmak için Collab özelliğini aç.', 'native.widget.dayOf': '{m} günün {n}. günü', 'native.widget.today': 'Bugün', 'native.widget.noTrip': 'Henüz planlanmış gezi yok', diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index 403dd6537..93ab27630 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -101,6 +101,14 @@ const native: TranslationStrings = { 'Запитує під час відкриття TREK і після пів хвилини у фоні, а в перемикачі застосунків вміст приховано.', 'native.share.title': 'До якої подорожі імпортувати?', 'native.share.noTrips': 'Подорожей ще немає. Спершу створіть подорож, а потім знову поділіться документом.', + 'native.share.linkTitle': 'Що TREK зробити з цим посиланням?', + 'native.share.saveLink': 'Зберегти в посиланнях подорожі', + 'native.share.saveLinkHint': 'Усі учасники подорожі знайдуть його в розділі «Посилання»', + 'native.share.readBooking': 'Прочитати з нього бронювання', + 'native.share.readBookingHint': 'Для сторінки підтвердження', + 'native.share.linkTrip': 'У яку подорож зберегти посилання?', + 'native.share.linkSaved': 'Посилання збережено', + 'native.share.linkUnsupported': 'Увімкніть Collab, щоб зберігати посилання в подорожі.', 'native.widget.dayOf': 'День {n} з {m}', 'native.widget.today': 'Сьогодні', 'native.widget.noTrip': 'Подорожей ще не заплановано', diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index a98207573..0a4d3bdc4 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -103,6 +103,14 @@ const native: TranslationStrings = { 'Hỏi khi mở TREK và sau nửa phút chạy nền, đồng thời ẩn ứng dụng trong trình chuyển ứng dụng.', 'native.share.title': 'Nhập vào chuyến đi nào?', 'native.share.noTrips': 'Chưa có chuyến đi nào. Hãy tạo một chuyến trước rồi chia sẻ lại tài liệu.', + 'native.share.linkTitle': 'TREK nên làm gì với liên kết này?', + 'native.share.saveLink': 'Lưu vào liên kết của chuyến đi', + 'native.share.saveLinkHint': 'Mọi người trong chuyến đi đều thấy trong mục Liên kết', + 'native.share.readBooking': 'Đọc đặt chỗ từ liên kết', + 'native.share.readBookingHint': 'Cho trang xác nhận', + 'native.share.linkTrip': 'Lưu liên kết vào chuyến đi nào?', + 'native.share.linkSaved': 'Đã lưu liên kết', + 'native.share.linkUnsupported': 'Bật Collab để lưu liên kết trong chuyến đi.', 'native.widget.dayOf': 'Ngày {n}/{m}', 'native.widget.today': 'Hôm nay', 'native.widget.noTrip': 'Chưa có chuyến đi nào được lên kế hoạch', diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index 19a89a2cb..a1364ac1e 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -98,6 +98,14 @@ const native: TranslationStrings = { 'native.lock.settingHint': '開啟 TREK 時以及在背景停留半分鐘後會要求驗證,並在 App 切換器中隱藏內容。', 'native.share.title': '要匯入到哪個行程?', 'native.share.noTrips': '還沒有行程。請先建立一個,然後再次分享該文件。', + 'native.share.linkTitle': 'TREK 應如何處理此連結?', + 'native.share.saveLink': '儲存到行程連結', + 'native.share.saveLinkHint': '行程中的所有人都能在「連結」中找到', + 'native.share.readBooking': '從中讀取預訂', + 'native.share.readBookingHint': '適用於確認頁面', + 'native.share.linkTrip': '將連結儲存到哪個行程?', + 'native.share.linkSaved': '連結已儲存', + 'native.share.linkUnsupported': '開啟 Collab 以在行程中保存連結。', 'native.widget.dayOf': '第 {n} 天,共 {m} 天', 'native.widget.today': '今天', 'native.widget.noTrip': '還沒有計畫中的行程', diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index 143fe699e..276b869b1 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -98,6 +98,14 @@ const native: TranslationStrings = { 'native.lock.settingHint': '打开 TREK 时以及在后台停留半分钟后会要求验证,并在应用切换器中隐藏内容。', 'native.share.title': '导入到哪个行程?', 'native.share.noTrips': '还没有行程。请先创建一个,然后再次分享该文档。', + 'native.share.linkTitle': 'TREK 应如何处理此链接?', + 'native.share.saveLink': '保存到行程链接', + 'native.share.saveLinkHint': '行程中的所有人都能在“链接”中找到', + 'native.share.readBooking': '从中读取预订', + 'native.share.readBookingHint': '适用于确认页面', + 'native.share.linkTrip': '将链接保存到哪个行程?', + 'native.share.linkSaved': '链接已保存', + 'native.share.linkUnsupported': '开启 Collab 以在行程中保存链接。', 'native.widget.dayOf': '第 {n} 天,共 {m} 天', 'native.widget.today': '今天', 'native.widget.noTrip': '还没有计划中的行程',