mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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': 'لا توجد رحلة مخططة بعد',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Δεν υπάρχει ακόμα προγραμματισμένο ταξίδι',
|
||||
|
||||
@@ -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': 'עדיין לא תוכנן טיול',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'まだ予定している旅行はありません',
|
||||
|
||||
@@ -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': '아직 계획된 여행이 없습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Поездок пока не запланировано',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ยังไม่มีทริปที่วางแผนไว้',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Подорожей ще не заплановано',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '還沒有計畫中的行程',
|
||||
|
||||
@@ -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': '还没有计划中的行程',
|
||||
|
||||
Reference in New Issue
Block a user