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.
 
 
 
 
 
 

890 lines
27 KiB

<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold">
{{ dialogHeading }}
</h1>
<div :class="closeButtonClasses" @click="close()">
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</div>
<!-- FEEDBACK: Show if camera preview is not visible after mounting -->
<div v-if="shouldShowCameraFeedback" :class="cameraFeedbackClasses">
<strong>Camera preview not started.</strong>
<div><b>Status:</b> {{ cameraFeedbackMessage }}</div>
</div>
<div class="mt-4">
<template v-if="isRegistered">
<div v-if="!blob">
<div :class="sectionDividerClasses">
<span :class="sectionDividerSpanClasses">
Take a photo with your camera
</span>
</div>
<div v-if="shouldShowCameraPreview" :class="cameraPreviewClasses">
<!-- Diagnostic Panel -->
<div v-if="showDiagnostics" :class="diagnosticsPanelClasses">
<div class="grid grid-cols-2 gap-2">
<div>
<p><strong>Camera State:</strong> {{ cameraState }}</p>
<p>
<strong>State Message:</strong>
{{ cameraStateMessage || "None" }}
</p>
<p><strong>Error:</strong> {{ error || "None" }}</p>
<p>
<strong>Preview Active:</strong>
{{ showCameraPreview ? "Yes" : "No" }}
</p>
<p>
<strong>Stream Active:</strong>
{{ !!cameraStream ? "Yes" : "No" }}
</p>
</div>
<div>
<p><strong>Browser:</strong> {{ userAgent }}</p>
<p>
<strong>HTTPS:</strong>
{{ isSecureContext ? "Yes" : "No" }}
</p>
<p>
<strong>MediaDevices:</strong>
{{ hasMediaDevices ? "Yes" : "No" }}
</p>
<p>
<strong>GetUserMedia:</strong>
{{ hasGetUserMedia ? "Yes" : "No" }}
</p>
<p>
<strong>Platform:</strong>
{{ platformCapabilities.isMobile ? "Mobile" : "Desktop" }}
</p>
</div>
</div>
</div>
<!-- Toggle Diagnostics Button -->
<button
:class="diagnosticsToggleClasses"
@click="toggleDiagnostics"
>
{{ diagnosticsToggleText }}
</button>
<div class="camera-container w-full h-full relative">
<video
ref="videoElement"
class="camera-video w-full h-full object-cover"
autoplay
playsinline
muted
></video>
<div :class="cameraControlsClasses">
<button
:class="cameraControlButtonClasses"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
<button
v-if="shouldShowCameraRotation"
:class="cameraControlButtonClasses"
@click="rotateCamera"
>
<font-awesome icon="rotate" class="w-[1em]" />
</button>
</div>
</div>
</div>
<div :class="sectionDividerClasses">
<span :class="sectionDividerSpanClasses">
OR choose a file from your device
</span>
</div>
<div class="mt-4">
<input
type="file"
:class="fileInputClasses"
@change="uploadImageFile"
/>
</div>
<div :class="sectionDividerClasses">
<span :class="sectionDividerSpanClasses">
OR paste an image URL
</span>
</div>
<div class="flex items-center gap-2 mt-4">
<input
v-model="imageUrl"
type="text"
:class="urlInputClasses"
placeholder="https://example.com/image.jpg"
/>
<button
v-if="imageUrl"
:class="acceptUrlButtonClasses"
@click="acceptUrl"
>
<font-awesome icon="check" class="fa-fw" />
</button>
</div>
</div>
<div v-else>
<div v-if="uploading" class="flex justify-center">
<font-awesome
icon="spinner"
class="fa-spin fa-3x text-center block px-12 py-12"
/>
</div>
<div v-else>
<div v-if="crop">
<VuePictureCropper
:box-style="{
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="blobUrl"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 1 / 1,
}"
:class="cropperClasses"
/>
</div>
<div v-else>
<div class="flex justify-center">
<img :src="blobUrl" :class="imageContainerClasses" />
</div>
</div>
<div :class="buttonGridClasses">
<button :class="primaryButtonClasses" @click="uploadImage">
<span>Upload</span>
</button>
<button
v-if="showRetry"
:class="secondaryButtonClasses"
@click="retryImage"
>
<span>Retry</span>
</button>
</div>
</div>
</div>
</template>
<template v-else>
<div
id="noticeBeforeUpload"
:class="registrationNoticeClasses"
role="alert"
aria-live="polite"
>
<p class="mb-2">
Before you can upload a photo, a friend needs to register you.
</p>
<button
:class="registrationButtonClasses"
@click="handleQRCodeClick"
>
Share Your Info
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<script lang="ts">
import axios from "axios";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { Capacitor } from "@capacitor/core";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import {
NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR,
NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR,
NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR,
NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR,
createImageDialogCameraErrorMessage,
createImageDialogUploadErrorMessage,
IMAGE_DIALOG_TIMEOUT_LONG,
IMAGE_DIALOG_TIMEOUT_MODAL,
} from "../constants/notifications";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { Prop } from "vue-facing-decorator";
import { Router } from "vue-router";
const inputImageFileNameRef = ref<Blob>();
@Component({
components: { VuePictureCropper },
mixins: [PlatformServiceMixin],
})
export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
/** Active DID for user authentication */
activeDid = "";
/** Current image blob being processed */
blob?: Blob;
/** Type of claim for the image */
claimType: string = "";
/** Whether to show cropping interface */
crop: boolean = false;
/** Name of the selected file */
fileName?: string;
/** Callback function to set image URL after upload */
imageCallback: (imageUrl: string) => void = () => {};
/** URL for image input */
imageUrl?: string;
/** Whether to show retry button */
showRetry = true;
/** Upload progress state */
uploading = false;
/** Dialog visibility state */
visible = false;
/** Whether to show camera preview */
showCameraPreview = false;
/** Camera stream reference */
private cameraStream: MediaStream | null = null;
/** Current camera facing mode */
private currentFacingMode: "environment" | "user" = "environment";
URL = window.URL || window.webkitURL;
private platformCapabilities = this.platformService.getCapabilities();
// Add diagnostic properties
showDiagnostics = false;
userAgent = navigator.userAgent;
isSecureContext = window.isSecureContext;
hasMediaDevices = !!navigator.mediaDevices;
hasGetUserMedia = !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
);
cameraState:
| "off"
| "initializing"
| "ready"
| "active"
| "error"
| "permission_denied"
| "not_found"
| "in_use" = "off";
cameraStateMessage?: string;
error: string | null = null;
// Props
@Prop({ default: true }) isRegistered!: boolean;
@Prop({
default: "environment",
validator: (value: string) => ["environment", "user"].includes(value),
})
defaultCameraMode!: string;
/**
* Computed property for dialog heading text
* Determines the appropriate heading based on current state
*/
get dialogHeading(): string {
if (this.uploading) return "Uploading Image…";
if (this.blob) return this.crop ? "Crop Image" : "Preview Image";
if (this.showCameraPreview) return "Upload Image";
return "Add Photo";
}
/**
* Computed property for camera preview visibility
* Determines if camera preview should be shown
*/
get shouldShowCameraPreview(): boolean {
return this.showCameraPreview && !this.blob;
}
/**
* Computed property for camera feedback visibility
* Shows feedback when camera preview is not visible after mounting
*/
get shouldShowCameraFeedback(): boolean {
return !this.showCameraPreview && !this.blob && this.isRegistered;
}
/**
* Computed property for camera feedback message
* Provides appropriate feedback based on camera state
*/
get cameraFeedbackMessage(): string {
if (this.cameraState === "off") {
if (this.platformCapabilities.isMobile) {
return "This mobile browser may not support direct camera access, or the app is treating it as a native app. Try using a desktop browser, or check if your browser supports camera access for web apps.";
}
return "Your browser supports camera APIs, but the preview did not start. Try refreshing the page or checking browser permissions.";
}
if (this.cameraState === "error") {
return this.error || this.cameraStateMessage || "Unknown error occurred.";
}
return this.cameraStateMessage || "Unknown reason.";
}
/**
* Computed property for button grid classes
* Determines grid layout based on retry button visibility
*/
get buttonGridClasses(): string {
return `grid gap-2 mt-2 ${this.showRetry ? "grid-cols-2" : "grid-cols-1"}`;
}
/**
* Computed property for blob URL
* Creates object URL for blob display
*/
get blobUrl(): string {
return this.blob ? this.createBlobURL(this.blob) : "";
}
/**
* Computed property for diagnostics toggle button text
* Determines button text based on diagnostics visibility
*/
get diagnosticsToggleText(): string {
return this.showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics";
}
/**
* Computed property for camera rotation button visibility
* Shows rotation button only on mobile platforms
*/
get shouldShowCameraRotation(): boolean {
return this.platformCapabilities.isMobile;
}
/**
* Computed property for close button classes
* Provides consistent styling for the close button
*/
get closeButtonClasses(): string {
return "text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0";
}
/**
* Computed property for camera feedback container classes
* Provides consistent styling for camera feedback messages
*/
get cameraFeedbackClasses(): string {
return "bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm";
}
/**
* Computed property for section divider classes
* Provides consistent styling for section dividers
*/
get sectionDividerClasses(): string {
return "border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm";
}
/**
* Computed property for section divider span classes
* Provides consistent styling for divider labels
*/
get sectionDividerSpanClasses(): string {
return "block w-fit mx-auto -mb-2.5 bg-white px-2";
}
/**
* Computed property for camera preview container classes
* Provides consistent styling for camera preview
*/
get cameraPreviewClasses(): string {
return "camera-preview relative flex bg-black overflow-hidden mb-4";
}
/**
* Computed property for diagnostics panel classes
* Provides consistent styling for diagnostics overlay
*/
get diagnosticsPanelClasses(): string {
return "absolute top-0 left-0 right-0 bg-black/80 text-white text-xs p-2 pt-8 z-20 overflow-auto max-h-[50vh]";
}
/**
* Computed property for diagnostics toggle button classes
* Provides consistent styling for diagnostics toggle
*/
get diagnosticsToggleClasses(): string {
return "absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30";
}
/**
* Computed property for camera control button classes
* Provides consistent styling for camera control buttons
*/
get cameraControlButtonClasses(): string {
return "bg-white text-slate-800 p-3 rounded-full text-2xl leading-none";
}
/**
* Computed property for camera controls container classes
* Provides consistent styling for camera controls
*/
get cameraControlsClasses(): string {
return "absolute bottom-4 inset-x-0 flex items-center justify-center gap-4";
}
/**
* Computed property for file input classes
* Provides consistent styling for file input with custom button
*/
get fileInputClasses(): string {
return "w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2";
}
/**
* Computed property for URL input classes
* Provides consistent styling for URL input field
*/
get urlInputClasses(): string {
return "block w-full rounded border border-slate-400 px-4 py-2";
}
/**
* Computed property for accept URL button classes
* Provides consistent styling for accept URL button
*/
get acceptUrlButtonClasses(): string {
return "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-3 py-2 rounded-md cursor-pointer";
}
/**
* Computed property for image container classes
* Provides consistent styling for image display
*/
get imageContainerClasses(): string {
return "mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain";
}
/**
* Computed property for cropper container classes
* Provides consistent styling for image cropper
*/
get cropperClasses(): string {
return "max-h-[50vh] max-w-[90vw] object-contain";
}
/**
* Computed property for primary button classes
* Provides consistent styling for primary action buttons
*/
get primaryButtonClasses(): string {
return "bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md";
}
/**
* Computed property for secondary button classes
* Provides consistent styling for secondary action buttons
*/
get secondaryButtonClasses(): string {
return "bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md";
}
/**
* Computed property for registration notice classes
* Provides consistent styling for registration notice
*/
get registrationNoticeClasses(): string {
return "bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3";
}
/**
* Computed property for registration button classes
* Provides consistent styling for registration button
*/
get registrationButtonClasses(): string {
return "inline-block text-md uppercase 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-4 py-2 rounded-md";
}
/**
* Lifecycle hook: Initializes component and retrieves user settings
* @throws {Error} When settings retrieval fails
*/
async mounted() {
try {
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
} catch (error: unknown) {
logger.error("Error retrieving settings from database:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
error instanceof Error
? error.message
: NOTIFY_IMAGE_DIALOG_SETTINGS_ERROR.message,
},
IMAGE_DIALOG_TIMEOUT_MODAL,
);
}
}
/**
* Lifecycle hook: Cleans up camera stream when component is destroyed
*/
beforeDestroy() {
this.stopCameraPreview();
}
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
logger.debug("ImageMethodDialog.open called");
this.claimType = claimType;
this.crop = !!crop;
this.imageCallback = setImageFn;
this.visible = true;
this.currentFacingMode = this.defaultCameraMode as "environment" | "user";
// Start camera preview immediately
logger.debug("Starting camera preview from open()");
this.startCameraPreview();
}
async uploadImageFile(event: Event) {
const target = event.target as HTMLInputElement;
if (!target.files) return;
inputImageFileNameRef.value = target.files[0];
const file = inputImageFileNameRef.value;
if (file != null) {
const reader = new FileReader();
reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer;
if (data) {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.blob = blob;
this.fileName = (file as File).name;
this.showRetry = false;
}
};
reader.readAsArrayBuffer(file as Blob);
}
}
async acceptUrl() {
if (this.crop) {
try {
const urlBlobResponse = await axios.get(this.imageUrl as string, {
responseType: "blob",
});
const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string;
this.blob = urlBlobResponse.data as Blob;
this.fileName = fileName;
this.showRetry = false;
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: NOTIFY_IMAGE_DIALOG_RETRIEVAL_ERROR.message,
},
IMAGE_DIALOG_TIMEOUT_LONG,
);
}
} else {
this.imageCallback(this.imageUrl as string);
this.close();
}
}
close() {
this.visible = false;
this.stopCameraPreview();
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = undefined;
this.showCameraPreview = false;
}
async startCameraPreview() {
logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
logger.debug(
"getUserMedia available:",
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
);
try {
this.cameraState = "initializing";
this.cameraStateMessage = "Requesting camera access...";
this.showCameraPreview = true;
await this.$nextTick();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera API not available in this browser");
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this.currentFacingMode },
});
logger.debug("Camera access granted");
this.cameraStream = stream;
this.cameraState = "active";
this.cameraStateMessage = "Camera is active";
await this.$nextTick();
const videoElement = this.$refs.videoElement as HTMLVideoElement;
if (videoElement) {
videoElement.srcObject = stream;
await new Promise((resolve) => {
videoElement.onloadedmetadata = () => {
videoElement
.play()
.then(() => {
logger.debug("Video element started playing");
resolve(true);
})
.catch((error) => {
logger.error("Error playing video:", error);
throw error;
});
};
});
} else {
logger.error("Video element not found");
throw new Error("Video element not found");
}
} catch (error) {
logger.error("Error starting camera preview:", error);
const errorMessage = createImageDialogCameraErrorMessage(
error instanceof Error ? error : new Error("Unknown camera error"),
);
this.cameraState = "error";
this.cameraStateMessage = errorMessage;
this.error = errorMessage;
this.showCameraPreview = false;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
IMAGE_DIALOG_TIMEOUT_LONG,
);
}
}
stopCameraPreview() {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
this.showCameraPreview = false;
this.cameraState = "off";
this.cameraStateMessage = "Camera stopped";
this.error = null;
}
async capturePhoto() {
if (!this.cameraStream) return;
try {
const videoElement = this.$refs.videoElement as HTMLVideoElement;
const canvas = document.createElement("canvas");
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`;
this.showRetry = true;
this.stopCameraPreview();
}
},
"image/jpeg",
0.95,
);
} catch (error) {
logger.error("Error capturing photo:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: NOTIFY_IMAGE_DIALOG_CAPTURE_ERROR.message,
},
IMAGE_DIALOG_TIMEOUT_LONG,
);
}
}
async rotateCamera() {
// Toggle between front and back cameras
this.currentFacingMode =
this.currentFacingMode === "environment" ? "user" : "environment";
// Stop current stream
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
// Start new stream with updated facing mode
await this.startCameraPreview();
}
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
async retryImage() {
this.blob = undefined;
if (!this.platformCapabilities.isNativeApp) {
await this.startCameraPreview();
}
}
async uploadImage() {
this.uploading = true;
if (this.crop) {
this.blob = (await cropper?.getBlob()) || undefined;
}
const token = await accessToken(this.activeDid);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: NOTIFY_IMAGE_DIALOG_NO_IMAGE_ERROR.message,
},
IMAGE_DIALOG_TIMEOUT_LONG,
);
this.uploading = false;
this.close();
return;
}
formData.append("image", this.blob, this.fileName || "photo.jpg");
formData.append("claimType", this.claimType);
try {
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 },
);
this.uploading = false;
this.close();
this.imageCallback(response.data.url as string);
} catch (error: unknown) {
const errorMessage = createImageDialogUploadErrorMessage(error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
IMAGE_DIALOG_TIMEOUT_LONG,
);
this.uploading = false;
this.blob = undefined;
this.close();
}
}
// Add toggle method
toggleDiagnostics() {
this.showDiagnostics = !this.showDiagnostics;
}
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
}
</script>
<style>
.dialog-overlay {
z-index: 50;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Add styles for diagnostic panel */
.diagnostic-panel {
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
}
</style>