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
@@ -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);
}
@@ -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
@@ -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<ShareItem.Kind, String> {
val ext = name.substringAfterLast('.', "").lowercase()
@@ -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)
+5
View File
@@ -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)
}
@@ -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)
}
+35 -10
View File
@@ -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 {
@@ -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<typeof import('../../native/sharedFiles')>()),
saveSharedLink: vi.fn(),
}));
vi.mock('react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router')>()),
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<ReturnType<typeof tripRepo.list>>);
});
const page = () => new File(['<html></html>'], '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(<NativeImportSheet />);
@@ -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(<NativeImportSheet />);
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(<NativeImportSheet />);
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(<NativeImportSheet />);
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(<NativeImportSheet />);
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(<NativeImportSheet />);
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(<NativeImportSheet />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
@@ -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<TripRow[]>([]);
const [picked, setPicked] = useState<LinkChoice | null>(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 (
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
<div className="px-[18px] pb-4 pt-4">
<div className="mb-3">
<TileHeader
icon={<FileText size={19} strokeWidth={1.8} />}
title={t('native.share.title')}
sub={file?.name}
icon={link ? <Link2 size={19} strokeWidth={1.8} /> : <FileText size={19} strokeWidth={1.8} />}
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')}
/>
</div>
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
{trips.map((trip) => (
<button
key={trip.id}
type="button"
onClick={() => pick(trip.id)}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
>
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{trip.title}
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
))}
{trips.length === 0 && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
{t('native.share.noTrips')}
</p>
)}
</div>
{asking && (
<div className="flex flex-col gap-2">
<ChoiceRow
icon={<Link2 size={17} strokeWidth={2} />}
label={t('native.share.saveLink')}
hint={t('native.share.saveLinkHint')}
onClick={() => setPicked('link')}
/>
<ChoiceRow
icon={<ScanText size={17} strokeWidth={2} />}
label={t('native.share.readBooking')}
hint={t('native.share.readBookingHint')}
onClick={() => setPicked('booking')}
/>
</div>
)}
{nowhere && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
{t('native.share.linkUnsupported')}
</p>
)}
{showTrips && (
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
{trips.map((trip) => (
<button
key={trip.id}
type="button"
disabled={saving}
onClick={() => pick(trip.id)}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98] disabled:opacity-50"
>
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{trip.title}
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
))}
{trips.length === 0 && (
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
{t('native.share.noTrips')}
</p>
)}
</div>
)}
</div>
</MSheet>
);
}
function ChoiceRow({
icon,
label,
hint,
onClick,
}: {
icon: React.ReactNode;
label: string;
hint: string;
onClick: () => void;
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
>
<span
className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
aria-hidden="true"
>
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
{label}
</span>
<span className="block truncate text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">{hint}</span>
</span>
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
</button>
);
}
+32 -2
View File
@@ -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('<html></html>') };
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();
+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 {
+4
View File
@@ -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;
}
/**
+8
View File
@@ -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': 'لا توجد رحلة مخططة بعد',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'Δεν υπάρχει ακόμα προγραμματισμένο ταξίδι',
+8
View File
@@ -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': 'עדיין לא תוכנן טיול',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'まだ予定している旅行はありません',
+8
View File
@@ -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': '아직 계획된 여행이 없습니다',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'Поездок пока не запланировано',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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': 'ยังไม่มีทริปที่วางแผนไว้',
+8
View File
@@ -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',
+8
View File
@@ -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': 'Подорожей ще не заплановано',
+8
View File
@@ -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',
+8
View File
@@ -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': '還沒有計畫中的行程',
+8
View File
@@ -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': '还没有计划中的行程',