diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 6c6871e7f..f91c5d338 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -121,6 +121,21 @@ jobs: working-directory: client run: npm run cap:sync -- ios + # SwiftPM leaves a half-downloaded binary target (Capacitor's xcframeworks) + # behind when a download from GitHub fails, and every later attempt then + # stops on "already exists in file system". Resolve on a clean artifact + # cache, up to three times, before the build needs the packages. + - name: Resolve the Swift packages + working-directory: client/ios/App + run: | + for attempt in 1 2 3; do + rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts + if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi + echo "Resolving the Swift packages failed (attempt $attempt of 3)" + sleep 20 + done + exit 1 + # No -sdk: the scheme builds the embedded watch app and its complications # against the watchOS simulator SDK and the rest against the iOS one. - name: Build for the simulator @@ -220,6 +235,21 @@ jobs: xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS xcrun simctl list runtimes + # SwiftPM leaves a half-downloaded binary target (Capacitor's xcframeworks) + # behind when a download from GitHub fails, and every later attempt then + # stops on "already exists in file system". Resolve on a clean artifact + # cache, up to three times, before the build needs the packages. + - name: Resolve the Swift packages + working-directory: client/ios/App + run: | + for attempt in 1 2 3; do + rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts + if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi + echo "Resolving the Swift packages failed (attempt $attempt of 3)" + sleep 20 + done + exit 1 + - name: Build the watch app for the simulator working-directory: client/ios/App run: | diff --git a/client/src/mobile/components/NativeImportSheet.tsx b/client/src/mobile/components/NativeImportSheet.tsx index 91350f43f..4643756a2 100644 --- a/client/src/mobile/components/NativeImportSheet.tsx +++ b/client/src/mobile/components/NativeImportSheet.tsx @@ -8,7 +8,7 @@ * could not read, can only be kept; with Collaboration off, a page can only be read. */ import { ChevronRight, FileText, Link2, ScanText } from 'lucide-react'; -import React, { useEffect, useEffectEvent, useState } from 'react'; +import React, { useEffect, useEffectEvent, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { useToast } from '../../components/shared/Toast'; import { useTranslation } from '../../i18n'; @@ -36,16 +36,30 @@ function linkChoicesOf(file: File, collabOn: boolean): LinkChoice[] { return choices; } -/** Where the sheet stands for the shared item: what it asks, and what a tap on a trip does. */ -function stageOf(link: SharedLink | null, choices: LinkChoice[], picked: LinkChoice | null) { - if (!link) return { choice: 'booking' as LinkChoice | null, asking: false, nowhere: false }; - const choice = picked ?? (choices.length === 1 ? choices[0] : null); - return { choice, asking: choice == null && choices.length > 1, nowhere: choices.length === 0 }; +interface StageInput { + file: File | null; + link: SharedLink | null; + collabOn: boolean; + picked: LinkChoice | null; + /** The share sheet's trip could not take the link: the trips are offered after all. */ + presetFailed: boolean; } -function titleKeyOf(asking: boolean, choice: LinkChoice | null): string { - if (asking) return 'native.share.linkTitle'; - return choice === 'link' ? 'native.share.linkTrip' : 'native.share.title'; +/** Where the sheet stands for the shared item: what it asks, and what a tap on a trip does. */ +function stageOf({ file, link, collabOn, picked, presetFailed }: StageInput) { + if (!file || !link) return { choice: 'booking' as LinkChoice | null, asking: false, nowhere: false, showTrips: true }; + const choices = linkChoicesOf(file, collabOn); + const choice = picked ?? (choices.length === 1 ? choices[0] : null); + const asking = choice == null && choices.length > 1; + const nowhere = choices.length === 0; + // While the share sheet's trip takes the link there is nothing to choose. + const presetTakes = link.presetTripId != null && choice === 'link' && !presetFailed; + return { choice, asking, nowhere, showTrips: !asking && !nowhere && !presetTakes }; +} + +function sheetTitle(t: (key: string) => string, asking: boolean, choice: LinkChoice | null): string { + if (asking) return t('native.share.linkTitle'); + return choice === 'link' ? t('native.share.linkTrip') : t('native.share.title'); } /** The trips to choose from, those still ahead first. */ @@ -82,12 +96,17 @@ export default function NativeImportSheet(): React.ReactElement | null { const collabOn = useAddonStore((s) => s.isEnabled('collab')); // Both belong to the item they were made for, so a newly shared one starts afresh. const [pick, setPick] = useState<{ file: File; choice: LinkChoice } | null>(null); - const [savingFile, setSavingFile] = useState(null); - const picked = file && pick?.file === file ? pick.choice : null; - const saving = !!file && savingFile === file; - - const choices = file && link ? linkChoicesOf(file, collabOn) : []; - const { choice, asking, nowhere } = stageOf(link, choices, picked); + const [failedFile, setFailedFile] = useState(null); + // The save in flight, kept off the state: it guards a second tap, it is not shown. + const savingRef = useRef(null); + const picked = pick && pick.file === file ? pick.choice : null; + const { choice, asking, nowhere, showTrips } = stageOf({ + file, + link, + collabOn, + picked, + presetFailed: failedFile != null && failedFile === file, + }); const open = !!file && tripId == null; const trips = useTripRows(open); @@ -98,33 +117,34 @@ export default function NativeImportSheet(): React.ReactElement | null { void navigate(`/trips/${tripId}`); }, [file, tripId, pathname, navigate]); - const saveLink = async (id: number) => { - if (!link || !file || saving) return; - setSavingFile(file); - 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'))); - setSavingFile(null); - } + const saveLink = (id: number) => { + if (!link || !file || savingRef.current === file) return; + savingRef.current = file; + saveSharedLink(id, link).then( + () => { + toast.success(t('native.share.linkSaved')); + setFile(null); + void navigate(`/trips/${id}?tab=collab`); + }, + () => { + toast.error(offlineOr(t, t('common.error'))); + savingRef.current = null; + setFailedFile(file); + } + ); }; // The trip the share sheet picked, used as soon as it is clear what the link is for. const preset = link?.presetTripId ?? null; const applyPreset = useEffectEvent((id: number, use: LinkChoice) => { if (use === 'booking') chooseTrip(id); - else void saveLink(id); + else saveLink(id); }); useEffect(() => { if (preset != null && choice != null && file) applyPreset(preset, choice); }, [preset, choice, file]); - const pickTrip = (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); + const pickTrip = (id: number) => (choice === 'link' ? saveLink(id) : chooseTrip(id)); return ( setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}> @@ -132,7 +152,7 @@ export default function NativeImportSheet(): React.ReactElement | null {
: } - title={t(titleKeyOf(asking, choice))} + title={sheetTitle(t, asking, choice)} sub={link ? link.title : file?.name} onClose={() => setFile(null)} closeLabel={t('common.close')} @@ -144,7 +164,7 @@ export default function NativeImportSheet(): React.ReactElement | null { {t('native.share.linkUnsupported')}

)} - {showTrips && } + {showTrips && }
); @@ -170,15 +190,7 @@ function LinkQuestion({ onPick }: { onPick: (choice: LinkChoice) => void }): Rea ); } -function TripPicker({ - trips, - disabled, - onPick, -}: { - trips: TripRow[]; - disabled: boolean; - onPick: (id: number) => void; -}): React.ReactElement { +function TripPicker({ trips, onPick }: { trips: TripRow[]; onPick: (id: number) => void }): React.ReactElement { const { t } = useTranslation(); return (
@@ -186,7 +198,6 @@ function TripPicker({