Implement Contacts Page and Improve Identity Management Features

- Added a new ContactsPage component for managing and displaying user contacts.
- Introduced a sidebar link to navigate to the Contacts page.
- Updated identity management features in IdentitiesPage, including options to export, copy, and upload identity keys.
- Enhanced identity restoration process with improved user feedback and error handling.
- Refactored existing components to streamline contact fetching and display logic.
This commit is contained in:
Sudo-Ivan
2026-03-06 00:36:38 -06:00
parent 8615066ce2
commit d8d147581c
10 changed files with 1841 additions and 1771 deletions
+32 -6
View File
@@ -170,6 +170,19 @@
</SidebarLink>
</li>
<!-- contacts -->
<li>
<SidebarLink :to="{ name: 'contacts' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="account-multiple"
class="w-6 h-6 text-gray-700 dark:text-white"
/>
</template>
<template #text>{{ $t("app.contacts") }}</template>
</SidebarLink>
</li>
<!-- nomad network -->
<li>
<SidebarLink :to="{ name: 'nomadnetwork' }" :is-collapsed="isSidebarCollapsed">
@@ -481,7 +494,7 @@
>
<div class="w-full max-w-sm bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">LXMF Address QR</h3>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Identity QR (LXMA)</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 transition-colors"
@@ -503,13 +516,13 @@
v-if="config?.lxmf_address_hash"
class="text-xs font-mono text-gray-700 dark:text-zinc-200 text-center break-words"
>
{{ config.lxmf_address_hash }}
{{ getMyIdentityUri() }}
</div>
<div class="flex justify-center">
<button
type="button"
class="px-3 py-1.5 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline"
@click="copyValue(config?.lxmf_address_hash, $t('app.lxmf_address'))"
@click="copyIdentityUri"
>
{{ $t("common.copy") }}
</button>
@@ -1006,13 +1019,25 @@ export default {
async openLxmfQr() {
if (!this.config?.lxmf_address_hash) return;
try {
const uri = `lxmf://${this.config.lxmf_address_hash}`;
const uri = this.getMyIdentityUri();
this.lxmfQrDataUrl = await QRCode.toDataURL(uri, { margin: 1, scale: 6 });
this.showLxmfQr = true;
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
getMyIdentityUri() {
if (!this.config?.lxmf_address_hash) return null;
const publicKey = this.config?.identity_public_key;
return publicKey
? `lxma://${this.config.lxmf_address_hash}:${publicKey}`
: `lxmf://${this.config.lxmf_address_hash}`;
},
async copyIdentityUri() {
const uri = this.getMyIdentityUri();
if (!uri) return;
await this.copyValue(uri, "Identity URI");
},
async updateConfig(config, label = null) {
try {
WebSocketConnection.send(
@@ -1356,8 +1381,9 @@ export default {
},
handleProtocolLink(url) {
try {
// lxmf://<hash> or rns://<hash>
const hash = url.replace("lxmf://", "").replace("rns://", "").split("/")[0].replace("/", "");
// lxma://<hash>:<pubkey> or lxmf://<hash> or rns://<hash>
const cleanUrl = url.replace("lxma://", "").replace("lxmf://", "").replace("rns://", "");
const hash = cleanUrl.split(":")[0].split("/")[0].replace("/", "");
if (hash && hash.length === 32) {
this.$router.push({
name: "messages",
@@ -438,7 +438,8 @@ export default {
// fetch telephone contacts
const contactResponse = await window.axios.get("/api/v1/telephone/contacts");
this.contacts = Array.isArray(contactResponse.data) ? contactResponse.data : [];
this.contacts =
contactResponse.data?.contacts ?? (Array.isArray(contactResponse.data) ? contactResponse.data : []);
} catch (e) {
console.error("Failed to load command palette data:", e);
}
@@ -804,94 +804,6 @@
</div>
</div>
</div>
<!-- Identity Section -->
<div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
<div class="flex items-center gap-4 text-red-500">
<v-icon icon="mdi-key-alert" size="24"></v-icon>
<div class="space-y-0.5">
<div class="font-black text-sm tracking-tight">Identity Key Control</div>
<div class="text-[10px] font-bold uppercase tracking-widest opacity-70 italic">
Critical Security Warning
</div>
</div>
</div>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="danger-chip !px-5 !py-2.5"
@click="downloadIdentityFile"
>
<v-icon icon="mdi-file-export" start></v-icon>
Export Key File
</button>
<button
type="button"
class="secondary-chip !border-red-200 dark:!border-red-900/50 !text-red-600 dark:!text-red-400 !px-5 !py-2.5"
@click="copyIdentityBase32"
>
<v-icon icon="mdi-content-copy" start></v-icon>
Copy Base32 Key
</button>
</div>
<div class="space-y-4 pt-4 border-t border-red-500/10">
<div class="text-[10px] font-black text-red-500/60 uppercase tracking-widest">
Restore Identity
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button
type="button"
class="secondary-chip flex-1 !border-dashed !border-2 !rounded-2xl !py-4"
@click="$refs.identityFileInput.click()"
>
<v-icon icon="mdi-upload" start></v-icon>
Upload Key File
</button>
<input
ref="identityFileInput"
type="file"
accept=".identity,.bin,.key"
class="hidden"
@change="onIdentityRestoreFileChange"
/>
<div
class="text-center sm:py-2 text-[10px] font-black text-zinc-400 uppercase italic px-2 shrink-0 self-center"
>
or
</div>
<button
type="button"
class="secondary-chip flex-1 !border-dashed !border-2 !rounded-2xl !py-4"
@click="showIdentityPaste = !showIdentityPaste"
>
<v-icon icon="mdi-clipboard-text" start></v-icon>
Paste Base32
</button>
</div>
<transition name="fade-blur">
<div v-if="showIdentityPaste" class="space-y-3">
<textarea
v-model="identityRestoreBase32"
rows="4"
placeholder="Paste your base32 identity key here..."
class="w-full bg-white dark:bg-zinc-950 p-4 rounded-xl font-mono text-xs border border-zinc-100 dark:border-zinc-800 focus:outline-none focus:ring-2 focus:ring-red-500/20"
></textarea>
<button
type="button"
class="danger-chip w-full !py-3 !rounded-xl"
:disabled="identityRestoreInProgress"
@click="restoreIdentityBase32"
>
<span v-if="identityRestoreInProgress">Restoring...</span>
<span v-else>Confirm Key Restore</span>
</button>
</div>
</transition>
</div>
</div>
</div>
</div>
</div>
@@ -943,21 +855,9 @@ export default {
autoBackupsTotal: 0,
autoBackupsOffset: 0,
autoBackupsLimit: 3,
identityBackupMessage: "",
identityBackupError: "",
identityBase32: "",
identityBase32Message: "",
identityBase32Error: "",
identityRestoreInProgress: false,
identityRestoreMessage: "",
identityRestoreError: "",
identityRestoreFileName: "",
identityRestoreFile: null,
identityRestoreBase32: "",
electronVersion: null,
chromeVersion: null,
nodeVersion: null,
showIdentityPaste: false,
showContactDev: false,
};
},
@@ -1354,100 +1254,6 @@ export default {
this.restoreMessage = "";
}
},
async downloadIdentityFile() {
this.identityBackupMessage = "";
this.identityBackupError = "";
try {
const response = await window.axios.get("/api/v1/identity/backup/download", {
responseType: "blob",
});
const blob = new Blob([response.data], { type: "application/octet-stream" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "identity");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
ToastUtils.success(this.$t("about.identity_exported"));
} catch {
this.identityBackupError = "Failed to download identity";
}
},
async copyIdentityBase32() {
this.identityBase32Message = "";
this.identityBase32Error = "";
try {
const response = await window.axios.get("/api/v1/identity/backup/base32");
this.identityBase32 = response.data.identity_base32 || "";
if (!this.identityBase32) {
this.identityBase32Error = "No identity available";
return;
}
await navigator.clipboard.writeText(this.identityBase32);
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
ToastUtils.success(this.$t("about.identity_copied"));
} catch {
this.identityBase32Error = "Failed to copy identity";
}
},
onIdentityRestoreFileChange(event) {
const files = event.target.files;
if (files && files[0]) {
this.identityRestoreFile = files[0];
this.identityRestoreFileName = files[0].name;
this.identityRestoreError = "";
this.identityRestoreMessage = "";
}
},
async restoreIdentityFile() {
if (this.identityRestoreInProgress) {
return;
}
if (!this.identityRestoreFile) {
this.identityRestoreError = "Select an identity file to restore.";
return;
}
this.identityRestoreInProgress = true;
this.identityRestoreMessage = "";
this.identityRestoreError = "";
try {
const formData = new FormData();
formData.append("file", this.identityRestoreFile);
const response = await window.axios.post("/api/v1/identity/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch {
this.identityRestoreError = "Identity restore failed";
} finally {
this.identityRestoreInProgress = false;
}
},
async restoreIdentityBase32() {
if (this.identityRestoreInProgress) {
return;
}
if (!this.identityRestoreBase32) {
this.identityRestoreError = "Provide a base32 key to restore.";
return;
}
this.identityRestoreInProgress = true;
this.identityRestoreMessage = "";
this.identityRestoreError = "";
try {
const response = await window.axios.post("/api/v1/identity/restore", {
base32: this.identityRestoreBase32.trim(),
});
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch {
this.identityRestoreError = "Identity restore failed";
} finally {
this.identityRestoreInProgress = false;
}
},
formatRecoveryResult(value) {
if (value === null || value === undefined) {
return "—";
@@ -0,0 +1,711 @@
<template>
<div class="flex flex-1 min-w-0 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div class="max-w-5xl mx-auto space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100">{{ $t("contacts.title") }}</h1>
<p class="text-sm text-gray-600 dark:text-zinc-400">
{{ $t("contacts.description") }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="secondary-chip" @click="openMyIdentityDialog">
<MaterialDesignIcon icon-name="qrcode" class="size-4" />
{{ $t("contacts.share_my_identity") }}
</button>
<button type="button" class="primary-chip" @click="openAddDialog">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("contacts.add_contact") }}
</button>
</div>
</div>
<div class="glass-card space-y-3">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
<input
v-model="contactsSearch"
type="text"
:placeholder="$t('contacts.search_placeholder')"
class="input-field"
@input="onContactsSearchInput"
/>
</div>
</div>
<div class="glass-card">
<template v-if="isLoading && contacts.length === 0">
<div
v-for="i in 8"
:key="'skeleton-' + i"
class="rounded-2xl border border-gray-100 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/50 px-4 py-3 flex items-center gap-3"
>
<div
class="size-10 sm:size-12 rounded-full bg-gray-200 dark:bg-zinc-700 animate-pulse shrink-0"
/>
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 w-32 bg-gray-200 dark:bg-zinc-700 rounded animate-pulse" />
<div class="h-3 w-48 bg-gray-100 dark:bg-zinc-800 rounded animate-pulse" />
</div>
</div>
</template>
<div
v-else-if="!isLoading && contacts.length === 0"
class="py-10 text-center text-gray-500 dark:text-zinc-400"
>
{{ $t("contacts.no_contacts") }}
</div>
<div v-else class="space-y-2">
<div
v-for="contact in contacts"
:key="contact.id"
class="rounded-2xl border border-gray-100 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/50 px-4 py-3 flex items-center gap-3 hover:border-blue-300 dark:hover:border-blue-700 transition-colors cursor-default"
@contextmenu.prevent="openContextMenu($event, contact)"
>
<div class="flex-shrink-0">
<LxmfUserIcon
:custom-image="contact.custom_image"
:icon-name="contact.remote_icon ? contact.remote_icon.icon_name : ''"
:icon-foreground-colour="
contact.remote_icon ? contact.remote_icon.foreground_colour : ''
"
:icon-background-colour="
contact.remote_icon ? contact.remote_icon.background_colour : ''
"
icon-class="size-10 sm:size-12"
/>
</div>
<div class="min-w-0 flex-1">
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">
{{ contact.name }}
</div>
<div class="text-xs font-mono text-gray-500 dark:text-zinc-400 break-all">
{{ contact.lxmf_address || contact.remote_identity_hash }}
</div>
</div>
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-700 dark:hover:text-zinc-200 transition-colors"
:title="$t('contacts.actions')"
@click.stop="openContextMenu($event, contact)"
>
<MaterialDesignIcon icon-name="dots-vertical" class="size-5" />
</button>
</div>
<div v-if="hasMoreContacts && !isLoadingMore" class="pt-2 flex justify-center">
<button type="button" class="secondary-chip" @click="loadMoreContacts">
{{ $t("contacts.load_more") }}
</button>
</div>
<div v-if="isLoadingMore" class="py-3 flex justify-center">
<div
class="size-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Contact context menu -->
<div
v-if="contextMenu.visible"
class="fixed z-[210] min-w-48 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl"
:style="{ top: `${contextMenu.y}px`, left: `${contextMenu.x}px` }"
>
<button type="button" class="context-item" @click="shareContact(contextMenu.contact)">
<MaterialDesignIcon icon-name="share-variant" class="size-4" />
{{ $t("contacts.share_contact") }}
</button>
<button type="button" class="context-item" @click="copyContactUri(contextMenu.contact)">
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
{{ $t("contacts.copy_contact_uri") }}
</button>
<button
type="button"
class="context-item text-red-600 dark:text-red-400"
@click="removeContact(contextMenu.contact)"
>
<MaterialDesignIcon icon-name="delete-outline" class="size-4" />
{{ $t("contacts.remove_contact") }}
</button>
</div>
<!-- Add contact dialog -->
<div
v-if="isAddDialogOpen"
class="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="closeAddDialog"
>
<div class="w-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-zinc-100">{{ $t("contacts.add_contact") }}</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="closeAddDialog"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-xs uppercase tracking-wider font-semibold text-gray-500 mb-1">
{{ $t("contacts.contact_name_optional") }}
</label>
<input
v-model="newContactName"
type="text"
class="input-field"
:placeholder="$t('contacts.contact_name_placeholder')"
/>
</div>
<div>
<label class="block text-xs uppercase tracking-wider font-semibold text-gray-500 mb-1">
{{ $t("contacts.hash_or_uri") }}
</label>
<input
v-model="newContactInput"
type="text"
class="input-field font-mono"
:placeholder="$t('contacts.hash_or_uri_placeholder')"
@keydown.enter.prevent="submitAddContact"
/>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="secondary-chip" @click="pasteFromClipboard">
<MaterialDesignIcon icon-name="clipboard-text-outline" class="size-4" />
{{ $t("contacts.paste") }}
</button>
<button v-if="cameraSupported" type="button" class="secondary-chip" @click="openScannerDialog">
<MaterialDesignIcon icon-name="qrcode-scan" class="size-4" />
{{ $t("contacts.scan_qr") }}
</button>
</div>
</div>
<div class="px-5 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end gap-2">
<button type="button" class="secondary-chip" @click="closeAddDialog">
{{ $t("common.cancel") }}
</button>
<button
type="button"
class="primary-chip"
:disabled="!newContactInput || isSubmitting"
@click="submitAddContact"
>
<MaterialDesignIcon
:icon-name="isSubmitting ? 'loading' : 'check'"
class="size-4"
:class="{ 'animate-spin': isSubmitting }"
/>
{{ $t("contacts.add_contact") }}
</button>
</div>
</div>
</div>
<!-- Scanner dialog -->
<div
v-if="isScannerDialogOpen"
class="fixed inset-0 z-[220] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
@click.self="closeScannerDialog"
>
<div class="w-full max-w-xl rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-zinc-100">{{ $t("contacts.scan_qr") }}</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="closeScannerDialog"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-5 space-y-3">
<video
ref="scannerVideo"
class="w-full rounded-xl bg-black max-h-[60vh]"
autoplay
playsinline
muted
></video>
<div class="text-sm text-gray-500 dark:text-zinc-400">
{{ scannerError || $t("contacts.scanner_hint") }}
</div>
</div>
</div>
</div>
<!-- My identity dialog -->
<div
v-if="isMyIdentityDialogOpen"
class="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="isMyIdentityDialogOpen = false"
>
<div class="w-full max-w-md rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-zinc-100">
{{ $t("contacts.share_my_identity") }}
</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="isMyIdentityDialogOpen = false"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-5 space-y-4">
<div class="flex justify-center">
<img
v-if="myQrDataUrl"
:src="myQrDataUrl"
alt="Identity QR"
class="w-52 h-52 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white"
/>
</div>
<div class="text-xs font-mono break-all text-center text-gray-600 dark:text-zinc-300">
{{ myIdentityUri }}
</div>
<div class="flex justify-center gap-2">
<button
type="button"
class="secondary-chip"
@click="copyToClipboard(myIdentityUri, $t('contacts.identity_uri_copied'))"
>
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
{{ $t("common.copy") }}
</button>
<button type="button" class="primary-chip" @click="shareUri(myIdentityUri)">
<MaterialDesignIcon icon-name="share-variant" class="size-4" />
{{ $t("contacts.share") }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import QRCode from "qrcode";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
export default {
name: "ContactsPage",
components: {
MaterialDesignIcon,
LxmfUserIcon,
},
data() {
return {
contacts: [],
contactsSearch: "",
isLoading: false,
isLoadingMore: false,
searchDebounceTimeout: null,
contactsPageSize: 30,
contactsOffset: 0,
totalContactsCount: 0,
config: null,
myIdentityUri: null,
myQrDataUrl: null,
isMyIdentityDialogOpen: false,
isAddDialogOpen: false,
isSubmitting: false,
newContactName: "",
newContactInput: "",
isScannerDialogOpen: false,
scannerError: null,
scannerStream: null,
scannerAnimationFrame: null,
pendingLxmaImport: false,
contextMenu: {
visible: false,
x: 0,
y: 0,
contact: null,
},
};
},
computed: {
cameraSupported() {
return (
typeof window !== "undefined" &&
typeof window.BarcodeDetector !== "undefined" &&
navigator?.mediaDevices?.getUserMedia
);
},
hasMoreContacts() {
return this.contacts.length < this.totalContactsCount;
},
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
document.removeEventListener("click", this.closeContextMenu);
this.stopScanner();
if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout);
}
},
async mounted() {
document.addEventListener("click", this.closeContextMenu);
WebSocketConnection.on("message", this.onWebsocketMessage);
await this.getConfig();
await this.getContacts();
},
methods: {
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
this.myIdentityUri = this.buildMyIdentityUri();
if (this.myIdentityUri) {
this.myQrDataUrl = await QRCode.toDataURL(this.myIdentityUri, { margin: 1, scale: 6 });
}
} catch (e) {
console.log(e);
}
},
buildMyIdentityUri() {
if (!this.config?.lxmf_address_hash) return null;
if (this.config?.identity_public_key) {
return `lxma://${this.config.lxmf_address_hash}:${this.config.identity_public_key}`;
}
return `lxmf://${this.config.lxmf_address_hash}`;
},
async getContacts(append = false) {
if (append) {
this.isLoadingMore = true;
} else {
this.isLoading = true;
this.contactsOffset = 0;
}
try {
const response = await window.axios.get("/api/v1/telephone/contacts", {
params: {
search: this.contactsSearch || undefined,
limit: this.contactsPageSize,
offset: this.contactsOffset,
},
});
const list = response.data?.contacts ?? (Array.isArray(response.data) ? response.data : []);
this.totalContactsCount = response.data?.total_count ?? list.length;
if (append) {
this.contacts = [...this.contacts, ...list];
} else {
this.contacts = list;
}
this.contactsOffset += list.length;
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("contacts.failed_load_contacts"));
} finally {
this.isLoading = false;
this.isLoadingMore = false;
}
},
loadMoreContacts() {
if (this.isLoadingMore || !this.hasMoreContacts) return;
this.getContacts(true);
},
onContactsSearchInput() {
if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
this.searchDebounceTimeout = setTimeout(() => {
this.getContacts();
}, 250);
},
openAddDialog() {
this.newContactName = "";
this.newContactInput = "";
this.pendingLxmaImport = false;
this.isAddDialogOpen = true;
},
closeAddDialog() {
this.isAddDialogOpen = false;
this.pendingLxmaImport = false;
},
openMyIdentityDialog() {
this.isMyIdentityDialogOpen = true;
},
parseLxmaUri(input) {
const normalized = input.trim();
const match = normalized.match(/^lxma:\/\/([0-9a-f]{32}):([0-9a-f]{64}|[0-9a-f]{128})$/i);
if (!match) return null;
return {
destinationHash: match[1].toLowerCase(),
publicKeyHex: match[2].toLowerCase(),
normalizedUri: `lxma://${match[1].toLowerCase()}:${match[2].toLowerCase()}`,
};
},
extractDestinationHash(input) {
const raw = input.trim().toLowerCase();
if (/^[0-9a-f]{32}$/.test(raw)) return raw;
const lxmfMatch = raw.match(/^lxmf:\/\/([0-9a-f]{32})$/);
if (lxmfMatch) return lxmfMatch[1];
const lxmMatch = raw.match(/^lxm:\/\/([0-9a-f]{32})$/);
if (lxmMatch) return lxmMatch[1];
return null;
},
async submitAddContact() {
if (!this.newContactInput || this.isSubmitting) return;
this.isSubmitting = true;
try {
const lxmaData = this.parseLxmaUri(this.newContactInput);
if (lxmaData) {
this.pendingLxmaImport = true;
WebSocketConnection.send(
JSON.stringify({
type: "lxm.ingest_uri",
uri: lxmaData.normalizedUri,
})
);
ToastUtils.info(this.$t("contacts.importing_lxma"));
return;
}
const destinationHash = this.extractDestinationHash(this.newContactInput);
if (!destinationHash) {
ToastUtils.error(this.$t("contacts.invalid_contact_input"));
return;
}
const existing = await window.axios.get(`/api/v1/telephone/contacts/check/${destinationHash}`);
if (existing.data?.id) {
ToastUtils.info(this.$t("contacts.contact_already_exists"));
return;
}
await window.axios.post("/api/v1/telephone/contacts", {
name: this.newContactName?.trim() || `Contact ${destinationHash.slice(0, 8)}`,
remote_identity_hash: destinationHash,
lxmf_address: destinationHash,
});
ToastUtils.success(this.$t("contacts.contact_added"));
this.closeAddDialog();
await this.getContacts();
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("contacts.failed_add_contact"));
} finally {
this.isSubmitting = false;
}
},
async onWebsocketMessage(message) {
let json;
try {
json = JSON.parse(message.data);
} catch {
return;
}
if (json.type === "lxm.ingest_uri.result" && this.pendingLxmaImport) {
this.pendingLxmaImport = false;
this.isSubmitting = false;
if (json.status === "success" && json.ingest_type === "lxma_contact") {
ToastUtils.success(json.message || this.$t("contacts.contact_added"));
this.closeAddDialog();
await this.getContacts();
} else if (json.status === "error") {
ToastUtils.error(json.message || this.$t("contacts.failed_add_contact"));
}
}
},
async removeContact(contact) {
this.closeContextMenu();
if (!contact?.id) return;
if (!window.confirm(this.$t("contacts.remove_contact_confirm"))) return;
try {
await window.axios.delete(`/api/v1/telephone/contacts/${contact.id}`);
ToastUtils.success(this.$t("contacts.contact_removed"));
await this.getContacts();
} catch {
ToastUtils.error(this.$t("contacts.failed_remove_contact"));
}
},
openContextMenu(event, contact) {
this.contextMenu.visible = true;
this.contextMenu.contact = contact;
this.contextMenu.x = event.clientX;
this.contextMenu.y = event.clientY;
},
closeContextMenu() {
this.contextMenu.visible = false;
this.contextMenu.contact = null;
},
async fetchContactLxmaUri(contact) {
const destinationHash = (contact?.lxmf_address || contact?.remote_identity_hash || "").toLowerCase();
if (!/^[0-9a-f]{32}$/.test(destinationHash)) return null;
try {
const response = await window.axios.get("/api/v1/announces", {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
const announce = response.data?.announces?.[0];
const publicKeyBase64 = announce?.identity_public_key;
if (!publicKeyBase64) return null;
const binary = atob(publicKeyBase64);
const publicKeyHex = Array.from(binary)
.map((c) => c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("");
if (publicKeyHex.length !== 128) return null;
return `lxma://${destinationHash}:${publicKeyHex}`;
} catch {
return null;
}
},
async copyContactUri(contact) {
this.closeContextMenu();
const lxmaUri = await this.fetchContactLxmaUri(contact);
if (lxmaUri) {
await this.copyToClipboard(lxmaUri, this.$t("contacts.contact_uri_copied"));
return;
}
const destinationHash = contact?.lxmf_address || contact?.remote_identity_hash;
if (destinationHash) {
await this.copyToClipboard(`lxmf://${destinationHash}`, this.$t("contacts.contact_uri_copied"));
} else {
ToastUtils.error(this.$t("contacts.failed_build_contact_uri"));
}
},
async shareContact(contact) {
this.closeContextMenu();
const lxmaUri = await this.fetchContactLxmaUri(contact);
const destinationHash = contact?.lxmf_address || contact?.remote_identity_hash;
const fallback = destinationHash ? `lxmf://${destinationHash}` : null;
const uri = lxmaUri || fallback;
if (!uri) {
ToastUtils.error(this.$t("contacts.failed_build_contact_uri"));
return;
}
await this.shareUri(uri);
},
async shareUri(uri) {
try {
if (navigator.share) {
await navigator.share({
title: this.$t("contacts.share"),
text: uri,
});
return;
}
} catch {
// ignore and fallback to clipboard
}
await this.copyToClipboard(uri, this.$t("contacts.contact_uri_copied"));
},
async copyToClipboard(value, successMessage) {
try {
await navigator.clipboard.writeText(value);
ToastUtils.success(successMessage || this.$t("common.copied"));
} catch {
ToastUtils.error(this.$t("common.failed_to_copy"));
}
},
async pasteFromClipboard() {
try {
this.newContactInput = await navigator.clipboard.readText();
} catch {
ToastUtils.error(this.$t("messages.failed_read_clipboard"));
}
},
async openScannerDialog() {
this.isScannerDialogOpen = true;
this.scannerError = null;
await this.$nextTick();
await this.startScanner();
},
async startScanner() {
if (!this.cameraSupported) {
this.scannerError = this.$t("contacts.camera_not_supported");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
audio: false,
});
this.scannerStream = stream;
const video = this.$refs.scannerVideo;
if (!video) return;
video.srcObject = stream;
await video.play();
this.detectQrLoop();
} catch (e) {
this.scannerError = e.message || this.$t("contacts.camera_failed");
}
},
detectQrLoop() {
if (!this.isScannerDialogOpen) return;
const video = this.$refs.scannerVideo;
if (!video || video.readyState < 2) {
this.scannerAnimationFrame = requestAnimationFrame(() => this.detectQrLoop());
return;
}
const detector = new window.BarcodeDetector({ formats: ["qr_code"] });
detector
.detect(video)
.then((barcodes) => {
const qr = barcodes?.[0]?.rawValue;
if (qr) {
this.newContactInput = qr.trim();
this.closeScannerDialog();
ToastUtils.success(this.$t("contacts.qr_scanned"));
} else {
this.scannerAnimationFrame = requestAnimationFrame(() => this.detectQrLoop());
}
})
.catch(() => {
this.scannerAnimationFrame = requestAnimationFrame(() => this.detectQrLoop());
});
},
stopScanner() {
if (this.scannerAnimationFrame) {
cancelAnimationFrame(this.scannerAnimationFrame);
this.scannerAnimationFrame = null;
}
if (this.scannerStream) {
this.scannerStream.getTracks().forEach((track) => track.stop());
this.scannerStream = null;
}
},
closeScannerDialog() {
this.isScannerDialogOpen = false;
this.stopScanner();
},
},
};
</script>
<style scoped>
.glass-card {
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-sm p-4;
}
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
}
.primary-chip {
@apply inline-flex items-center gap-1 rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 text-xs font-semibold transition disabled:opacity-60;
}
.secondary-chip {
@apply inline-flex items-center gap-1 rounded-xl bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 text-xs font-semibold transition;
}
.context-item {
@apply w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800 flex items-center gap-2;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -2117,7 +2117,7 @@ export default {
async fetchContacts() {
try {
const response = await window.axios.get("/api/v1/telephone/contacts");
this.contacts = response.data;
this.contacts = response.data?.contacts ?? (Array.isArray(response.data) ? response.data : []);
} catch (e) {
console.log("Failed to fetch contacts:", e);
}
@@ -3060,7 +3060,7 @@ export default {
async openShareContactModal() {
try {
const response = await window.axios.get("/api/v1/telephone/contacts");
this.contacts = response.data;
this.contacts = response.data?.contacts ?? (Array.isArray(response.data) ? response.data : []);
if (this.contacts.length === 0) {
ToastUtils.info(this.$t("messages.no_contacts_telephone"));
@@ -76,7 +76,8 @@
<div class="p-6">
<p class="text-sm text-gray-600 dark:text-zinc-400 mb-4">
You can read LXMF paper messages by scanning a QR code or pasting an <strong>lxmf://</strong> or
<strong>lxm://</strong> link.
<strong>lxm://</strong> link. Contact-sharing links using <strong>lxma://</strong> are also
supported.
</p>
<div class="space-y-4">
<div>
@@ -89,7 +90,7 @@
<input
v-model="ingestUri"
type="text"
placeholder="lxmf://..."
placeholder="lxmf://... or lxma://..."
class="block w-full rounded-lg border-0 py-2 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
@keydown.enter="ingestPaperMessage"
/>
@@ -308,7 +309,11 @@ export default {
case "lxm.ingest_uri.result": {
if (json.status === "success") {
this.ingestUri = "";
await this.getConversations();
if (json.ingest_type === "lxma_contact" && json.destination_hash) {
await this.onComposeNewMessage(json.destination_hash);
} else {
await this.getConversations();
}
}
break;
}
@@ -1063,6 +1063,11 @@ export default {
await this.processVisualization();
},
async processVisualization() {
await new Promise((r) => {
requestAnimationFrame(r);
});
if (this.abortController.signal.aborted) return;
this.loadingStatus = "Processing visualization...";
const processedNodeIds = new Set();
@@ -1158,6 +1163,9 @@ export default {
if (interfaceNodes.length > 0) this.nodes.update(interfaceNodes);
if (interfaceEdges.length > 0) this.edges.update(interfaceEdges);
await this.$nextTick();
if (this.abortController.signal.aborted) return;
// Process path table in batches to prevent UI block
this.totalNodesToLoad = this.pathTable.length;
this.loadedNodesCount = 0;
@@ -374,6 +374,8 @@ export default {
return {
GlobalState,
reloadInterval: null,
nodesRefreshTimeout: null,
abortController: new AbortController(),
nodes: {},
totalNodesCount: 0,
@@ -456,13 +458,12 @@ export default {
this.$nextTick(() => this.processPartials());
},
beforeUnmount() {
if (this.nodesRefreshTimeout) clearTimeout(this.nodesRefreshTimeout);
clearInterval(this.reloadInterval);
this.abortController.abort();
this.clearPartials();
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
// stop listening for element clicks
window.document.removeEventListener("click", this.onElementClick);
},
mounted() {
@@ -790,7 +791,6 @@ export default {
},
async getNomadnetworkNodeAnnounces(append = false) {
try {
// fetch announces for "nomadnetwork.node" aspect
const offset = append ? Object.keys(this.nodes).length : 0;
const response = await window.axios.get(`/api/v1/announces`, {
params: {
@@ -799,9 +799,9 @@ export default {
offset: offset,
search: this.nodesSearchTerm,
},
signal: this.abortController.signal,
});
// update ui
const nodeAnnounces = response.data.announces;
if (!append) {
this.nodes = {};
@@ -815,7 +815,7 @@ export default {
this.hasMoreNodes = nodeAnnounces.length === this.pageSize;
} catch (e) {
// do nothing if failed to load announces
if (window.axios.isCancel?.(e)) return;
console.log(e);
} finally {
this.isLoadingMoreNodes = false;
@@ -837,21 +837,20 @@ export default {
},
async getNomadnetworkNodeAnnounce(destinationHash) {
try {
// fetch announces for "nomadnetwork.node" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
signal: this.abortController.signal,
});
// update ui
const nodeAnnounces = response.data.announces;
for (const nodeAnnounce of nodeAnnounces) {
this.updateNodeFromAnnounce(nodeAnnounce);
}
} catch (e) {
// do nothing if failed to load announce
if (window.axios.isCancel?.(e)) return;
console.log(e);
}
},
@@ -24,9 +24,106 @@
</button>
</div>
<!-- export/import current identity (only when current exists) -->
<div
v-if="currentIdentity"
class="glass-card overflow-hidden border border-amber-500/20 dark:border-amber-500/10 bg-amber-50/30 dark:bg-amber-900/10"
>
<div class="p-5 space-y-4">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-400">
<MaterialDesignIcon icon-name="key-alert" class="w-5 h-5 shrink-0" />
<span class="font-semibold text-sm">{{ $t("identities.key_control") }}</span>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="secondary-chip"
@click="downloadIdentityFile"
>
<MaterialDesignIcon icon-name="file-export" class="w-4 h-4" />
{{ $t("identities.export_key_file") }}
</button>
<button
type="button"
class="secondary-chip"
@click="copyIdentityBase32"
>
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
{{ $t("identities.copy_base32") }}
</button>
<button
type="button"
class="secondary-chip"
@click="$refs.identityFileInput?.click()"
>
<MaterialDesignIcon icon-name="upload" class="w-4 h-4" />
{{ $t("identities.upload_key_file") }}
</button>
<input
ref="identityFileInput"
type="file"
accept=".identity,.bin,.key"
class="hidden"
@change="onIdentityRestoreFileChange"
/>
<button
type="button"
class="secondary-chip"
@click="showIdentityPaste = !showIdentityPaste"
>
<MaterialDesignIcon icon-name="clipboard-text" class="w-4 h-4" />
{{ $t("identities.paste_base32") }}
</button>
</div>
<div v-if="identityRestoreError" class="text-sm text-red-600 dark:text-red-400">
{{ identityRestoreError }}
</div>
<div v-if="identityRestoreMessage" class="text-sm text-green-600 dark:text-green-400">
{{ identityRestoreMessage }}
</div>
<div v-if="showIdentityPaste" class="space-y-2 pt-2 border-t border-amber-500/10">
<textarea
v-model="identityRestoreBase32"
rows="3"
class="input-field font-mono text-xs"
:placeholder="$t('identities.paste_base32_placeholder')"
/>
<button
type="button"
class="primary-chip"
:disabled="identityRestoreInProgress || !identityRestoreBase32.trim()"
@click="restoreIdentityBase32"
>
<MaterialDesignIcon
v-if="identityRestoreInProgress"
icon-name="loading"
class="w-4 h-4 animate-spin"
/>
{{ identityRestoreInProgress ? $t("identities.restoring") : $t("identities.confirm_restore") }}
</button>
</div>
</div>
</div>
<!-- identities list -->
<div class="grid gap-4">
<template v-if="isLoading && identities.length === 0">
<div
v-for="i in 4"
:key="'skel-' + i"
class="glass-card overflow-hidden p-5 flex items-center gap-4"
>
<div
class="w-14 h-14 rounded-2xl bg-gray-200 dark:bg-zinc-700 animate-pulse shrink-0"
/>
<div class="flex-1 min-w-0 space-y-2">
<div class="h-5 w-32 bg-gray-200 dark:bg-zinc-700 rounded animate-pulse" />
<div class="h-3 w-48 bg-gray-100 dark:bg-zinc-800 rounded animate-pulse" />
</div>
</div>
</template>
<div
v-else
v-for="identity in identities"
:key="identity.hash"
v-memo="[
@@ -35,6 +132,7 @@
identity.display_name,
identity.lxmf_address,
identity.lxst_address,
identity.message_count,
identity.icon_name,
identity.icon_background_colour,
identity.icon_foreground_colour,
@@ -119,6 +217,12 @@
>
LXST: {{ identity.lxst_address }}
</div>
<div
v-if="identity.message_count != null"
class="text-[10px] text-gray-400 dark:text-zinc-500 mt-0.5"
>
{{ $t("identities.message_count", { count: identity.message_count }) }}
</div>
</div>
<!-- actions -->
@@ -147,7 +251,7 @@
</div>
<!-- empty state -->
<div v-if="identities.length === 0" class="glass-card p-12 text-center">
<div v-if="!isLoading && identities.length === 0" class="glass-card p-12 text-center">
<div
class="w-20 h-20 bg-gray-100 dark:bg-zinc-800 rounded-3xl flex items-center justify-center mx-auto mb-4"
>
@@ -229,11 +333,23 @@ export default {
data() {
return {
identities: [],
isLoading: false,
showCreateModal: false,
newIdentityName: "",
isCreating: false,
showIdentityPaste: false,
identityRestoreBase32: "",
identityRestoreInProgress: false,
identityRestoreMessage: "",
identityRestoreError: "",
identityRestoreFile: null,
};
},
computed: {
currentIdentity() {
return this.identities.find((i) => i.is_current) || null;
},
},
mounted() {
this.getIdentities();
GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
@@ -247,12 +363,95 @@ export default {
this.isCreating = false;
},
async getIdentities() {
this.isLoading = true;
try {
const response = await window.axios.get("/api/v1/identities");
this.identities = response.data.identities;
this.identities = response.data?.identities ?? [];
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("identities.failed_load"));
} finally {
this.isLoading = false;
}
},
async downloadIdentityFile() {
try {
const response = await window.axios.get("/api/v1/identity/backup/download", {
responseType: "blob",
});
const blob = new Blob([response.data], { type: "application/octet-stream" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "identity");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
ToastUtils.success(this.$t("identities.identity_exported"));
} catch {
ToastUtils.error(this.$t("identities.identity_export_failed"));
}
},
async copyIdentityBase32() {
try {
const response = await window.axios.get("/api/v1/identity/backup/base32");
const base32 = response.data?.identity_base32 ?? "";
if (!base32) {
ToastUtils.error(this.$t("identities.no_identity_available"));
return;
}
await navigator.clipboard.writeText(base32);
ToastUtils.success(this.$t("identities.identity_copied"));
} catch {
ToastUtils.error(this.$t("identities.identity_copy_failed"));
}
},
onIdentityRestoreFileChange(event) {
const files = event.target.files;
if (files?.[0]) {
this.identityRestoreFile = files[0];
this.identityRestoreError = "";
this.identityRestoreMessage = "";
this.restoreIdentityFile();
}
event.target.value = "";
},
async restoreIdentityFile() {
if (this.identityRestoreInProgress || !this.identityRestoreFile) return;
this.identityRestoreInProgress = true;
this.identityRestoreMessage = "";
this.identityRestoreError = "";
try {
const formData = new FormData();
formData.append("file", this.identityRestoreFile);
const response = await window.axios.post("/api/v1/identity/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
this.identityRestoreMessage = response.data?.message ?? this.$t("identities.identity_restored");
this.identityRestoreFile = null;
} catch {
this.identityRestoreError = this.$t("identities.identity_restore_failed");
} finally {
this.identityRestoreInProgress = false;
}
},
async restoreIdentityBase32() {
if (this.identityRestoreInProgress || !this.identityRestoreBase32?.trim()) return;
this.identityRestoreInProgress = true;
this.identityRestoreMessage = "";
this.identityRestoreError = "";
try {
const response = await window.axios.post("/api/v1/identity/restore", {
base32: this.identityRestoreBase32.trim(),
});
this.identityRestoreMessage = response.data?.message ?? this.$t("identities.identity_restored");
this.identityRestoreBase32 = "";
this.showIdentityPaste = false;
} catch {
this.identityRestoreError = this.$t("identities.identity_restore_failed");
} finally {
this.identityRestoreInProgress = false;
}
},
async createIdentity() {