You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

379 lines
12 KiB

<!--
SharedPhotoView.vue - External Image Sharing Handler
Handles images shared to TimeSafari from external applications via deep linking.
Provides options to use shared images for gifts or profile pictures, with
integrated image upload and processing capabilities.
Key Features:
- Process externally shared images from device
- Upload images to image server with authentication
- Convert images to gifts (GiveAction) or profile pictures
- Temporary storage management for shared image data
- Comprehensive error handling for upload failures
- Integration with PhotoDialog for profile image cropping
Image Flow:
1. External app shares image TimeSafari deep link
2. Image stored temporarily in database as base64
3. User chooses: Record Gift, Save as Profile, or Cancel
4. Image uploaded to server with JWT authentication
5. Temporary storage cleaned up
Navigation Paths:
- External Share SharedPhotoView
- Record Gift GiftedDetailsView (with image URL)
- Save Profile PhotoDialog AccountView
- Cancel HomeView
Migration Status: Complete Enhanced Triple Migration Pattern
- Phase 1: Database Migration (PlatformServiceMixin)
- Phase 2: SQL Abstraction ($getTemp, $deleteTemp, $accountSettings, $updateSettings)
- Phase 3: Notification Migration (3 constants, helper methods)
- Phase 4: Template Streamlining (Simple template)
Author: Matthew Raymer
Last Updated: 2025-07-07
-->
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Image
</h1>
<div v-if="imageBlob">
<div v-if="uploading" class="text-center mb-4">
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else>
<div class="text-center mb-4">Choose how to use this image</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="recordGift"
>
<font-awesome icon="gift" class="fa-fw" />
Record a Gift
</button>
<button
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="recordProfile"
>
<font-awesome icon="circle-user" class="fa-fw" />
Save as Profile Image
</button>
<button
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="cancel"
>
<font-awesome icon="ban" class="fa-fw" />
Cancel
</button>
</div>
<PhotoDialog ref="photoDialog" />
</div>
<div class="flex justify-center">
<img
:src="URL.createObjectURL(imageBlob)"
alt="Shared Image"
class="rounded mt-4"
/>
</div>
</div>
<div v-else class="text-center mb-4">
<p>No image found.</p>
<p class="mt-4">
If you shared an image, the cause is usually that you do not have the
recent version of this app, or that the app has not refreshed the
service code underneath. To fix this, first make sure you have latest
version by comparing your version at the bottom of "Help" with the
version at the bottom of https://timesafari.app/help in a browser. After
that, it may eventually work, but you can speed up the process by
clearing your data cache (in the browser on mobile, even if you
installed it) and/or reinstalling the app (after backing up all your
data, of course).
</p>
</div>
</section>
</template>
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
Router,
} from "vue-router";
import PhotoDialog from "../components/PhotoDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import {
DEFAULT_IMAGE_API_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "../constants/app";
import { accessToken } from "../libs/crypto";
import { base64ToBlob, SHARED_PHOTO_BASE64_KEY } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import {
NOTIFY_SHARED_PHOTO_LOAD_ERROR,
NOTIFY_SHARED_PHOTO_SAVE_ERROR,
} from "@/constants/notifications";
@Component({
components: { PhotoDialog, QuickNav },
mixins: [PlatformServiceMixin],
})
export default class SharedPhotoView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
notify!: ReturnType<typeof createNotifyHelpers>;
/**
* Active user's DID for authentication and image ownership
*/
activeDid: string | undefined = undefined;
/**
* Blob data of the shared image for processing and upload
*/
imageBlob: Blob | undefined = undefined;
/**
* Original filename of the shared image from external app
*/
imageFileName: string | undefined = undefined;
/**
* Upload status indicator for UI feedback
*/
uploading = false;
/**
* Browser URL API for creating object URLs from blobs
*/
URL = window.URL || window.webkitURL;
/**
* Vue lifecycle hook - Initialize shared image processing
*
* Loads the shared image data from temporary storage, retrieves user settings,
* and prepares the component for image processing. Cleans up temporary storage
* after successful image loading.
*
* @async
*/
async mounted() {
this.notify = createNotifyHelpers(this.$notify);
try {
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid;
const temp = await this.$getTemp(SHARED_PHOTO_BASE64_KEY);
const imageB64 = temp?.blobB64 as string;
if (temp) {
this.imageBlob = base64ToBlob(imageB64);
// clear the temp image
await this.$deleteTemp(SHARED_PHOTO_BASE64_KEY);
this.imageFileName = this.$route.query["fileName"] as string;
} else {
logger.error("No appropriate image found in temp storage.", temp);
}
} catch (err: unknown) {
logger.error("Got an error loading an identifier:", err);
this.notify.error(
NOTIFY_SHARED_PHOTO_LOAD_ERROR.message,
TIMEOUTS.STANDARD,
);
}
}
/**
* Process shared image as a gift record
*
* Uploads the shared image to the server as a GiveAction and navigates
* to the gift details view with the uploaded image URL for gift recording.
*
* @async
*/
async recordGift() {
await this.sendToImageServer("GiveAction").then((url) => {
if (url) {
const route = {
name: "gifted-details",
// this might be wrong since "name" goes with params, but it works so test well when you change it
query: {
destinationPathAfter: "/",
hideBackButton: "true",
imageUrl: url,
recipientDid: this.activeDid,
},
} as RouteLocationRaw;
this.$router.push(route);
}
});
}
/**
* Process shared image as profile picture
*
* Opens the PhotoDialog component for image cropping and profile picture
* processing. Updates user settings with the new profile image URL and
* navigates to the account view upon completion.
*/
recordProfile() {
(this.$refs.photoDialog as PhotoDialog).open(
async (imgUrl) => {
await this.$updateSettings({ profileImageUrl: imgUrl });
this.$router.push({ name: "account" });
},
IMAGE_TYPE_PROFILE,
true,
this.imageBlob,
this.imageFileName,
);
}
/**
* Cancel image processing and return to home
*
* Clears the shared image data and navigates back to the home view,
* effectively canceling the image sharing operation.
*
* @async
*/
async cancel() {
this.imageBlob = undefined;
this.imageFileName = undefined;
this.$router.push({ name: "home" });
}
/**
* Upload image to the image server with comprehensive error handling
*
* Sends the shared image to the configured image server with JWT authentication.
* Provides detailed error handling for various failure scenarios including
* authentication, file size, format, and server errors.
*
* @param imageType - The type of image claim (e.g., "GiveAction")
* @returns Promise<string | undefined> - The uploaded image URL or undefined on failure
* @async
*/
async sendToImageServer(imageType: string) {
this.uploading = true;
let result;
try {
// send the image to the server
const token = await accessToken(this.activeDid);
const headers = {
Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
};
const formData = new FormData();
formData.append(
"image",
this.imageBlob as Blob,
this.imageFileName as string,
);
formData.append("claimType", imageType);
if (
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
if (response?.data?.url) {
this.imageBlob = undefined;
this.imageFileName = undefined;
result = response.data.url as string;
} else {
logger.error("Problem uploading the image", response.data);
const errorMessage =
NOTIFY_SHARED_PHOTO_SAVE_ERROR.message +
" " +
(response?.data?.message || "");
this.notify.error(errorMessage, TIMEOUTS.LONG);
}
this.uploading = false;
} catch (error) {
// Log the raw error first
logger.error("Raw error object:", JSON.stringify(error, null, 2));
let errorMessage = "There was an error saving the picture.";
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const statusText = error.response?.statusText;
const data = error.response?.data;
// Log detailed error information
logger.error("Upload error details:", {
status,
statusText,
data: JSON.stringify(data, null, 2),
message: error.message,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers,
},
});
if (status === 401) {
errorMessage = "Authentication failed. Please try logging in again.";
} else if (status === 413) {
errorMessage = "Image file is too large. Please try a smaller image.";
} else if (status === 415) {
errorMessage =
"Unsupported image format. Please try a different image.";
} else if (status && status >= 500) {
errorMessage = "Server error. Please try again later.";
} else if (data?.message) {
errorMessage = data.message;
}
} else if (error instanceof Error) {
// Log non-Axios error with full details
logger.error("Non-Axios error details:", {
name: error.name,
message: error.message,
stack: error.stack,
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
});
} else {
// Log any other type of error
logger.error("Unknown error type:", {
error: JSON.stringify(error, null, 2),
type: typeof error,
});
}
this.notify.error(errorMessage, TIMEOUTS.LONG);
this.uploading = false;
}
return result;
}
}
</script>