forked from trent_larson/crowd-funder-for-time-pwa
feat(ios): improve share extension UX and fix image reload issues
Improve iOS share extension implementation to skip interstitial UI and fix issues with subsequent image shares not updating the view. iOS Share Extension Improvements: - Replace SLComposeServiceViewController with custom UIViewController to eliminate interstitial "Post" button UI - Use minimal URL (timesafari://) instead of deep link for app launch - Implement app lifecycle detection via Capacitor appStateChange listener instead of relying solely on deep links Deep Link and Navigation Fixes: - Remove "shared-photo" from deep link schemas (no longer needed) - Add empty path URL handling for share extension launches - Implement processing lock to prevent duplicate image processing - Add retry mechanism (300ms delay) to handle race conditions with AppDelegate writing temp files - Use router.replace() when already on /shared-photo route to force refresh - Clear old images from temp DB before storing new ones - Delete temp file immediately after reading to prevent stale data SharedPhotoView Component: - Add route watcher (@Watch) to reload image when fileName query parameter changes - Extract image loading logic into reusable loadSharedImage() method - Improve error handling to clear image state on failures This fixes the issue where sharing a second image while already on SharedPhotoView would display the previous image instead of the new one.
This commit is contained in:
@@ -55,6 +55,9 @@ window.addEventListener("unhandledrejection", (event) => {
|
||||
|
||||
const deepLinkHandler = new DeepLinkHandler(router);
|
||||
|
||||
// Lock to prevent duplicate processing of shared images
|
||||
let isProcessingSharedImage = false;
|
||||
|
||||
/**
|
||||
* Handles deep link routing for the application
|
||||
* Processes URLs in the format timesafari://<route>/<param>
|
||||
@@ -79,11 +82,22 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
}> {
|
||||
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
|
||||
// Prevent duplicate processing
|
||||
if (isProcessingSharedImage) {
|
||||
logger.debug(
|
||||
"[Main] ⏸️ Shared image processing already in progress, skipping",
|
||||
);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
isProcessingSharedImage = true;
|
||||
|
||||
try {
|
||||
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
logger.debug("[Main] Checking for iOS shared image from App Group");
|
||||
|
||||
// Use Capacitor's native bridge to call the ShareImagePlugin
|
||||
@@ -92,6 +106,7 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
|
||||
if (!capacitor || !capacitor.Plugins) {
|
||||
logger.debug("[Main] Capacitor plugins not available");
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
@@ -117,6 +132,19 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
);
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// Clear old image from temp DB first to ensure we get the new one
|
||||
try {
|
||||
await platformService.dbExec("DELETE FROM temp WHERE id = ?", [
|
||||
SHARED_PHOTO_BASE64_KEY,
|
||||
]);
|
||||
logger.debug("[Main] Cleared old shared image from temp DB");
|
||||
} catch (clearError) {
|
||||
logger.debug(
|
||||
"[Main] No old image to clear (or error clearing):",
|
||||
clearError,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert raw base64 to data URL format that base64ToBlob expects
|
||||
// base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..."
|
||||
// Try to detect image type from base64 or default to jpeg
|
||||
@@ -133,17 +161,19 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
[SHARED_PHOTO_BASE64_KEY, dataUrl],
|
||||
);
|
||||
|
||||
// Delete the temp file
|
||||
// Delete the temp file immediately after reading to prevent re-reading
|
||||
try {
|
||||
await Filesystem.deleteFile({
|
||||
path: tempFilePath,
|
||||
directory: Directory.Documents,
|
||||
});
|
||||
logger.debug("[Main] Deleted temp file after reading");
|
||||
} catch (deleteError) {
|
||||
logger.error("[Main] Failed to delete temp file:", deleteError);
|
||||
}
|
||||
|
||||
logger.info(`[Main] Stored shared image: ${fileName}`);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: true, fileName };
|
||||
}
|
||||
}
|
||||
@@ -175,6 +205,7 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
result = await shareImagePlugin.getSharedImageData();
|
||||
} catch (pluginError) {
|
||||
logger.error("[Main] Plugin call failed:", pluginError);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
} else {
|
||||
@@ -206,12 +237,14 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
}
|
||||
} catch (executeError) {
|
||||
logger.error("[Main] Execute method failed:", executeError);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (!result || !result.success || !result.data) {
|
||||
logger.debug("[Main] No shared image data found in result");
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
@@ -219,6 +252,7 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
|
||||
if (!base64) {
|
||||
logger.debug("[Main] Shared image data missing base64");
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
@@ -226,18 +260,39 @@ async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
|
||||
// Store in temp database (similar to web flow)
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
// $insertEntity is available via PlatformServiceMixin
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (platformService as any).$insertEntity(
|
||||
"temp",
|
||||
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64 },
|
||||
["id", "blobB64"],
|
||||
|
||||
// Clear old image from temp DB first to ensure we get the new one
|
||||
try {
|
||||
await platformService.dbExec("DELETE FROM temp WHERE id = ?", [
|
||||
SHARED_PHOTO_BASE64_KEY,
|
||||
]);
|
||||
logger.debug("[Main] Cleared old shared image from temp DB");
|
||||
} catch (clearError) {
|
||||
logger.debug(
|
||||
"[Main] No old image to clear (or error clearing):",
|
||||
clearError,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert raw base64 to data URL format that base64ToBlob expects
|
||||
let mimeType = "image/jpeg"; // default
|
||||
if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) {
|
||||
mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
|
||||
}
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
// Use INSERT OR REPLACE to handle existing records
|
||||
await platformService.dbExec(
|
||||
"INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)",
|
||||
[SHARED_PHOTO_BASE64_KEY, dataUrl],
|
||||
);
|
||||
|
||||
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: true, fileName: fileName || "shared-image.jpg" };
|
||||
} catch (error) {
|
||||
logger.error("[Main] Error checking for native shared image:", error);
|
||||
isProcessingSharedImage = false;
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
@@ -247,41 +302,74 @@ const handleDeepLink = async (data: { url: string }) => {
|
||||
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
||||
|
||||
try {
|
||||
// Check if this is a shared-photo deep link from native share
|
||||
const isSharedPhotoLink = url.includes("timesafari://shared-photo");
|
||||
// Handle empty path URLs from share extension (timesafari://)
|
||||
// These are used to open the app, and we should check for shared images
|
||||
const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///";
|
||||
|
||||
if (
|
||||
isSharedPhotoLink &&
|
||||
isEmptyPathUrl &&
|
||||
Capacitor.isNativePlatform() &&
|
||||
Capacitor.getPlatform() === "ios"
|
||||
) {
|
||||
logger.debug(
|
||||
"[Main] 📸 Shared photo deep link detected, checking for native shared image",
|
||||
"[Main] 📸 Empty path URL from share extension, checking for native shared image",
|
||||
);
|
||||
|
||||
// Try to get shared image from App Group and store in temp database
|
||||
// AppDelegate writes the file when the deep link is received, so we may need to retry
|
||||
try {
|
||||
const imageResult = await checkAndStoreNativeSharedImage();
|
||||
let imageResult = await checkAndStoreNativeSharedImage();
|
||||
|
||||
// If not found immediately, wait a bit and retry (AppDelegate may still be writing the file)
|
||||
if (!imageResult.success) {
|
||||
logger.debug(
|
||||
"[Main] ⏳ Image not found immediately, waiting for AppDelegate to write file...",
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300)); // Wait 300ms
|
||||
imageResult = await checkAndStoreNativeSharedImage();
|
||||
}
|
||||
|
||||
if (imageResult.success) {
|
||||
logger.info("[Main] ✅ Native shared image stored in temp database");
|
||||
logger.info(
|
||||
"[Main] ✅ Native shared image found, navigating to shared-photo",
|
||||
);
|
||||
|
||||
// Add fileName to the URL as a query parameter if we have it
|
||||
if (imageResult.fileName) {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.set("fileName", imageResult.fileName);
|
||||
const modifiedUrl = urlObj.toString();
|
||||
data.url = modifiedUrl;
|
||||
logger.debug(`[Main] Added fileName to URL: ${modifiedUrl}`);
|
||||
// Wait for router to be ready
|
||||
await router.isReady();
|
||||
|
||||
// Navigate directly to shared-photo route
|
||||
// Use replace if already on shared-photo to force refresh, otherwise push
|
||||
const fileName = imageResult.fileName || "shared-image.jpg";
|
||||
const isAlreadyOnSharedPhoto =
|
||||
router.currentRoute.value.path === "/shared-photo";
|
||||
|
||||
if (isAlreadyOnSharedPhoto) {
|
||||
// Force refresh by replacing the route
|
||||
await router.replace({
|
||||
path: "/shared-photo",
|
||||
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
|
||||
});
|
||||
} else {
|
||||
await router.push({
|
||||
path: "/shared-photo",
|
||||
query: { fileName },
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`,
|
||||
);
|
||||
return; // Exit early, don't process as deep link
|
||||
} else {
|
||||
logger.debug(
|
||||
"[Main] ℹ️ No native shared image found (may be from web or already processed)",
|
||||
"[Main] ℹ️ No native shared image found, ignoring empty path URL",
|
||||
);
|
||||
return; // Exit early, don't process empty path as deep link
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[Main] Error processing native shared image:", error);
|
||||
// Continue with normal deep link processing even if native check fails
|
||||
// If check fails, don't process as deep link (empty path would fail validation anyway)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,10 +460,76 @@ const registerDeepLinkListener = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check for shared image and navigate to shared-photo route if found
|
||||
* This is called when app becomes active (from share extension or app launch)
|
||||
*/
|
||||
async function checkForSharedImageAndNavigate() {
|
||||
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("[Main] 🔍 Checking for shared image on app activation");
|
||||
const imageResult = await checkAndStoreNativeSharedImage();
|
||||
|
||||
if (imageResult.success) {
|
||||
logger.info(
|
||||
"[Main] ✅ Shared image found, navigating to shared-photo route",
|
||||
);
|
||||
|
||||
// Wait for router to be ready
|
||||
await router.isReady();
|
||||
|
||||
// Navigate to shared-photo route with fileName if available
|
||||
// Use replace if already on shared-photo to force refresh, otherwise push
|
||||
const fileName = imageResult.fileName || "shared-image.jpg";
|
||||
const isAlreadyOnSharedPhoto =
|
||||
router.currentRoute.value.path === "/shared-photo";
|
||||
|
||||
if (isAlreadyOnSharedPhoto) {
|
||||
// Force refresh by replacing the route
|
||||
await router.replace({
|
||||
path: "/shared-photo",
|
||||
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
|
||||
});
|
||||
} else {
|
||||
const route = imageResult.fileName
|
||||
? `/shared-photo?fileName=${encodeURIComponent(imageResult.fileName)}`
|
||||
: "/shared-photo";
|
||||
await router.push(route);
|
||||
}
|
||||
|
||||
logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`);
|
||||
} else {
|
||||
logger.debug("[Main] ℹ️ No shared image found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[Main] ❌ Error checking for shared image:", error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log("[Capacitor] 🚀 Mounting app");
|
||||
app.mount("#app");
|
||||
logger.info(`[Main] ✅ App mounted successfully`);
|
||||
|
||||
// Check for shared image on initial load (in case app was launched from share extension)
|
||||
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") {
|
||||
setTimeout(async () => {
|
||||
await checkForSharedImageAndNavigate();
|
||||
}, 1000); // Small delay to ensure router is ready
|
||||
}
|
||||
|
||||
// Listen for app state changes to detect when app becomes active
|
||||
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") {
|
||||
CapacitorApp.addListener("appStateChange", async ({ isActive }) => {
|
||||
if (isActive) {
|
||||
logger.debug("[Main] 📱 App became active, checking for shared image");
|
||||
await checkForSharedImageAndNavigate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register deeplink listener after app is mounted
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user