diff --git a/app/components/Toast.vue b/app/components/Toast.vue new file mode 100644 index 0000000..7b85ea2 --- /dev/null +++ b/app/components/Toast.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/app/components/dialog/Dialog.vue b/app/components/dialog/Dialog.vue index d0ed431..153bf97 100644 --- a/app/components/dialog/Dialog.vue +++ b/app/components/dialog/Dialog.vue @@ -5,7 +5,7 @@ class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50" >
diff --git a/app/pages/index.vue b/app/pages/index.vue index 42a04c6..61fcac8 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,6 +1,12 @@ @@ -83,12 +161,16 @@ import { startSendSession, store, updateAliasState, + showToast, + requestNotificationPermission, } from "@/services/store"; import { getAgentInfoString } from "~/utils/userAgent"; import { protocolVersion } from "~/services/webrtc"; import { generateRandomAlias } from "~/utils/alias"; import { useFileDialog } from "@vueuse/core"; import SessionDialog from "~/components/dialog/SessionDialog.vue"; +import InputDialog from "~/components/dialog/InputDialog.vue"; +import Toast from "~/components/Toast.vue"; import { cryptoKeyToPem, generateClientTokenFromCurrentTimestamp, @@ -108,68 +190,129 @@ const { t } = useI18n(); const { open: openFileDialog, onChange } = useFileDialog(); -onChange(async (files) => { - if (!files) return; +// --- Drag & Drop --- +const isDragging = ref(false); +let dragLeaveTimeout: ReturnType | null = null; - if (files.length === 0) return; +const onDragOver = () => { + if (dragLeaveTimeout) clearTimeout(dragLeaveTimeout); + isDragging.value = true; +}; - if (!store.signaling) return; +const onDragLeave = () => { + dragLeaveTimeout = setTimeout(() => { + isDragging.value = false; + }, 100); +}; - await startSendSession({ - files, - targetId: targetId.value, - onPin: async () => { - return prompt(t("index.enterPin")); - }, - }); -}); +const onDrop = async (e: DragEvent) => { + isDragging.value = false; + const files = e.dataTransfer?.files; + if (!files || files.length === 0) return; -const minDelayFinished = ref(false); -const webCryptoSupported = ref(true); + if (!store.signaling) { + showToast(t("index.dragDrop.noPeer"), "error"); + return; + } -const targetId = ref(""); + if (store.peers.length === 0) { + showToast(t("index.dragDrop.noPeer"), "error"); + return; + } -const selectPeer = (id: string) => { - targetId.value = id; - openFileDialog(); + // Send to the first available peer + const peerId = store.peers[0].id; + await startSendSession({ + files, + targetId: peerId, + onPin: requestTransferPin, + }); }; -const updateAlias = async () => { - if (!store.client) return; +// --- Custom Input Dialogs --- +const aliasDialogVisible = ref(false); +const pinDialogVisible = ref(false); +const transferPinDialogVisible = ref(false); +let transferPinResolve: ((value: string | null) => void) | null = null; - const current = store.client; - if (!current) return; +const showAliasDialog = () => { + aliasDialogVisible.value = true; +}; - const alias = prompt(t("index.enterAlias"), current.alias); - if (!alias || !store.signaling) return; +const onAliasConfirm = (value: string) => { + aliasDialogVisible.value = false; + if (!value || !store.signaling || !store.client) return; store.signaling.send({ type: "UPDATE", info: { - alias: alias, - version: current.version, - deviceModel: current.deviceModel, - deviceType: current.deviceType, - token: current.token, + alias: value, + version: store.client.version, + deviceModel: store.client.deviceModel, + deviceType: store.client.deviceType, + token: store.client.token, }, }); - updateAliasState(alias); + updateAliasState(value); }; -const updatePIN = async () => { - const pin = prompt(t("index.enterPin")); - if (typeof pin === "string") { - store.pin = pin ? pin : null; - } +const showPinDialog = () => { + pinDialogVisible.value = true; +}; + +const onPinConfirm = (value: string) => { + pinDialogVisible.value = false; + store.pin = value ? value : null; +}; + +const requestTransferPin = (): Promise => { + return new Promise((resolve) => { + transferPinResolve = resolve; + transferPinDialogVisible.value = true; + }); +}; + +const onTransferPinConfirm = (value: string) => { + transferPinDialogVisible.value = false; + transferPinResolve?.(value || null); + transferPinResolve = null; +}; + +const onTransferPinCancel = () => { + transferPinDialogVisible.value = false; + transferPinResolve?.(null); + transferPinResolve = null; +}; + +// --- File Selection via Click --- +onChange(async (files) => { + if (!files) return; + if (files.length === 0) return; + if (!store.signaling) return; + + await startSendSession({ + files, + targetId: targetId.value, + onPin: requestTransferPin, + }); +}); + +const minDelayFinished = ref(false); +const webCryptoSupported = ref(true); +const targetId = ref(""); + +const selectPeer = (id: string) => { + targetId.value = id; + openFileDialog(); }; onMounted(async () => { webCryptoSupported.value = isWebCryptoSupported(); + requestNotificationPermission(); + setTimeout(() => { - // to prevent flickering during initial connection - // i.e. show blank screen instead of "Connecting..." minDelayFinished.value = true; }, 1000); @@ -198,9 +341,7 @@ onMounted(async () => { await setupConnection({ url: runtimeConfig.public.signalingUrl, info, - onPin: async () => { - return prompt(t("index.enterPin")); - }, + onPin: requestTransferPin, }); }); @@ -214,4 +355,12 @@ onMounted(async () => { transform: rotate(360deg); } } +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} diff --git a/app/services/store.ts b/app/services/store.ts index 68041d6..e2be5a9 100644 --- a/app/services/store.ts +++ b/app/services/store.ts @@ -29,6 +29,16 @@ export type FileState = { error?: string; }; +export type ToastMessage = { + id: number; + message: string; + type: "success" | "error" | "info"; +}; + +export type ConnectionStatus = "connected" | "reconnecting" | "disconnected"; + +let toastIdCounter = 0; + export const store = reactive({ // Whether the connection loop has started _loopStarted: false, @@ -53,6 +63,12 @@ export const store = reactive({ // List of peers connected to the same room peers: [] as ClientInfo[], + // Connection status + connectionStatus: "disconnected" as ConnectionStatus, + + // Toast notifications + toasts: [] as ToastMessage[], + // Current session information session: { state: SessionState.idle, @@ -62,6 +78,32 @@ export const store = reactive({ }, }); +export function showToast(message: string, type: ToastMessage["type"] = "info") { + const id = ++toastIdCounter; + store.toasts.push({ id, message, type }); + setTimeout(() => dismissToast(id), 4000); +} + +export function dismissToast(id: number) { + store.toasts = store.toasts.filter((t) => t.id !== id); +} + +function showBrowserNotification(title: string, body: string) { + if (typeof window === "undefined") return; + if (document.visibilityState === "visible") return; + if ("Notification" in window && Notification.permission === "granted") { + new Notification(title, { body, icon: "/apple-touch-icon.png" }); + } +} + +export function requestNotificationPermission() { + if (typeof window !== "undefined" && "Notification" in window) { + if (Notification.permission === "default") { + Notification.requestPermission(); + } + } +} + export async function setupConnection({ url, info, @@ -82,6 +124,7 @@ export async function setupConnection({ async function connectionLoop(url: string) { while (true) { try { + store.connectionStatus = "reconnecting"; store.signaling = await SignalingConnection.connect({ url: url, info: store._proposingClient!, @@ -90,6 +133,7 @@ async function connectionLoop(url: string) { case "HELLO": store.client = data.client; store.peers = data.peers; + store.connectionStatus = "connected"; break; case "JOIN": store.peers = [...store.peers, data.peer]; @@ -120,13 +164,15 @@ async function connectionLoop(url: string) { store.signaling = null; store.client = null; store.peers = []; + store.connectionStatus = "disconnected"; }, }); await store.signaling.waitUntilClose(); } catch (error) { + store.connectionStatus = "reconnecting"; console.log("Retrying connection in 5 seconds..."); - await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, 5000)); } } } @@ -192,6 +238,10 @@ export async function startSendSession({ }, onFileProgress: onFileProgress, }); + showToast("Transfer complete!", "success"); + showBrowserNotification("LocalSend", "Files sent successfully!"); + } catch (error) { + showToast("Transfer failed", "error"); } finally { store.session.state = SessionState.idle; } @@ -250,6 +300,10 @@ export async function acceptOffer({ }, onFileProgress: onFileProgress, }); + showToast("Files received!", "success"); + showBrowserNotification("LocalSend", "Files received successfully!"); + } catch (error) { + showToast("Receiving failed", "error"); } finally { store.session.state = SessionState.idle; } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 51669a6..208e43d 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -20,7 +20,24 @@ "enterPin": "Enter PIN", "progress": { "titleSending": "Sending files...", - "titleReceiving": "Receiving files..." + "titleReceiving": "Receiving files...", + "total": "Total", + "files": "Files", + "transferComplete": "Transfer complete!", + "transferFailed": "Transfer failed" + }, + "connection": { + "connected": "Connected", + "reconnecting": "Reconnecting...", + "disconnected": "Disconnected" + }, + "dragDrop": { + "hint": "Drop files here to send", + "noPeer": "No device available to send files" + }, + "dialog": { + "confirm": "OK", + "cancel": "Cancel" } } }