Add stranger protection features and related UI enhancements

- Updated NotificationBell to handle additional notification types for missed calls and voicemails.
- Added buttons in CallPage for messaging and calling contacts directly from call history.
- Enhanced ContactsPage with context menu options for messaging and calling contacts.
- Introduced a stranger trust banner in ConversationViewer to inform users about non-contacts and provide options to add them.
- Implemented settings in SettingsPage to block attachments and messages from strangers, with corresponding configuration updates.
This commit is contained in:
Sudo-Ivan committed 2026-03-06 15:49:42 -06:00
1 parent ae4dfa1d8a
commit f335044ad3
5 files changed
+208 -3

No files matched your search

@@ -319,9 +319,12 @@ export default {
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
if (json.type === "lxmf.delivery") {
if (
json.type === "lxmf.delivery" ||
json.type === "telephone_missed_call" ||
json.type === "new_voicemail"
) {
await this.loadNotifications();
// If dropdown is open, mark new notifications as viewed
if (this.isDropdownOpen) {
await this.markNotificationsAsViewed();
}
@@ -777,6 +777,17 @@
>
<MaterialDesignIcon icon-name="account-remove" class="size-4" />
</button>
<button
type="button"
class="p-1.5 rounded-lg text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all shrink-0"
:title="$t('contacts.send_message')"
@click="openMessageFromHistory(entry)"
>
<MaterialDesignIcon
icon-name="message-text-outline"
class="size-4"
/>
</button>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1 bg-blue-600 text-white rounded-lg text-[10px] font-bold hover:bg-blue-500 transition-all shadow-md shadow-blue-500/10 shrink-0"
@@ -2734,6 +2745,11 @@ export default {
};
this.isContactModalOpen = true;
},
openMessageFromHistory(entry) {
const hash = entry.remote_destination_hash || entry.remote_identity_hash;
if (!hash) return;
this.$router.push({ name: "messages", params: { destinationHash: hash } });
},
async getHistory(loadMore = false) {
try {
if (!loadMore) {
@@ -60,7 +60,7 @@
<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"
class="group 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">
@@ -84,6 +84,24 @@
{{ contact.lxmf_address || contact.remote_identity_hash }}
</div>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-blue-100 dark:hover:bg-blue-900/40 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
:title="$t('contacts.send_message')"
@click.stop="openConversation(contact)"
>
<MaterialDesignIcon icon-name="message-text-outline" class="size-5" />
</button>
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-green-100 dark:hover:bg-green-900/40 hover:text-green-600 dark:hover:text-green-400 transition-colors"
:title="$t('contacts.call_contact')"
@click.stop="callContact(contact)"
>
<MaterialDesignIcon icon-name="phone-outline" class="size-5" />
</button>
</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"
@@ -114,6 +132,15 @@
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="openConversation(contextMenu.contact)">
<MaterialDesignIcon icon-name="message-text-outline" class="size-4" />
{{ $t("contacts.send_message") }}
</button>
<button type="button" class="context-item" @click="callContact(contextMenu.contact)">
<MaterialDesignIcon icon-name="phone-outline" class="size-4" />
{{ $t("contacts.call_contact") }}
</button>
<div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
<button type="button" class="context-item" @click="shareContact(contextMenu.contact)">
<MaterialDesignIcon icon-name="share-variant" class="size-4" />
{{ $t("contacts.share_contact") }}
@@ -122,6 +149,7 @@
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
{{ $t("contacts.copy_contact_uri") }}
</button>
<div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
<button
type="button"
class="context-item text-red-600 dark:text-red-400"
@@ -531,6 +559,19 @@ export default {
ToastUtils.error(this.$t("contacts.failed_remove_contact"));
}
},
openConversation(contact) {
this.closeContextMenu();
const hash = contact.remote_destination_hash || contact.lxmf_address || contact.remote_identity_hash;
if (!hash) return;
this.$router.push({ name: "messages", params: { destinationHash: hash } });
},
callContact(contact) {
this.closeContextMenu();
const hash =
contact.remote_telephony_hash || contact.remote_destination_hash || contact.remote_identity_hash;
if (!hash) return;
this.$router.push({ name: "call", query: { destination_hash: hash, tab: "phone" } });
},
openContextMenu(event, contact) {
this.contextMenu.visible = true;
this.contextMenu.contact = contact;
@@ -372,6 +372,42 @@
</div>
</div>
<!-- stranger trust banner -->
<div
v-if="isStrangerPeer && !strangerBannerDismissed"
class="mx-3 mt-2 mb-0 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded-lg flex items-center gap-3 text-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m0 3.75h.008v-.008H12v.008Zm9.303-5.626a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span class="flex-1 text-amber-900 dark:text-amber-200">
{{ $t("messages.stranger_banner_text") }}
</span>
<button
class="px-3 py-1 text-xs font-medium rounded-md bg-amber-600 hover:bg-amber-700 text-white transition-colors"
@click="addStrangerAsContact"
>
{{ $t("messages.add_to_contacts") }}
</button>
<button
class="px-2 py-1 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 transition-colors"
@click="strangerBannerDismissed = true"
>
{{ $t("messages.dismiss") }}
</button>
</div>
<!-- chat items -->
<div
id="messages"
@@ -1739,6 +1775,8 @@ export default {
expandedMessageInfo: null,
imageModalUrl: null,
isSelectedPeerBlocked: false,
isStrangerPeer: false,
strangerBannerDismissed: false,
isGeneratingPaperMessage: false,
generatedPaperMessageUri: null,
isPaperMessageResultModalOpen: false,
@@ -1991,6 +2029,8 @@ export default {
this.saveDraft(oldPeer.destination_hash);
}
this.checkIfSelectedPeerBlocked();
this.strangerBannerDismissed = false;
this.checkIfStrangerPeer();
this.initialLoad();
if (newPeer) {
this.loadDraft(newPeer.destination_hash);
@@ -2030,6 +2070,9 @@ export default {
// listen for compose new message event
GlobalEmitter.on("compose-new-message", this.onComposeNewMessageEvent);
// listen for contact updates to refresh stranger banner
GlobalEmitter.on("contact-updated", this.onContactUpdatedForBanner);
// check translator
this.checkTranslator();
@@ -2049,6 +2092,7 @@ export default {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
GlobalEmitter.off("contact-updated", this.onContactUpdatedForBanner);
if (this.propagationStatusInterval) {
clearInterval(this.propagationStatusInterval);
}
@@ -2177,6 +2221,54 @@ export default {
(b) => b.destination_hash === this.selectedPeer.destination_hash
);
},
onContactUpdatedForBanner(data) {
if (this.selectedPeer?.destination_hash === data?.remote_identity_hash) {
this.isStrangerPeer = false;
this.strangerBannerDismissed = true;
}
},
async checkIfStrangerPeer() {
if (!this.selectedPeer) {
this.isStrangerPeer = false;
return;
}
if (this.selectedPeer.is_contact || this.selectedPeer.contact_image) {
this.isStrangerPeer = false;
return;
}
try {
const response = await window.axios.get(
`/api/v1/telephone/contacts/check/${this.selectedPeer.destination_hash}`
);
this.isStrangerPeer = !response.data.is_contact;
} catch {
this.isStrangerPeer = !this.selectedPeer.is_contact && !this.selectedPeer.contact_image;
}
},
async addStrangerAsContact() {
if (!this.selectedPeer) return;
try {
const displayName =
this.selectedPeer.custom_display_name ?? this.selectedPeer.display_name ?? "Unknown";
await fetch(`/api/v1/telephone/contacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
remote_identity_hash: this.selectedPeer.destination_hash,
lxmf_address: this.selectedPeer.destination_hash,
name: displayName,
}),
});
this.isStrangerPeer = false;
this.strangerBannerDismissed = true;
GlobalEmitter.emit("contact-updated", {
remote_identity_hash: this.selectedPeer.destination_hash,
});
this.$emit("reload-conversations");
} catch (e) {
console.error("Failed to add contact:", e);
}
},
loadDraft(destinationHash) {
try {
const drafts = JSON.parse(localStorage.getItem("meshchat.drafts") || "{}");
@@ -142,6 +142,49 @@
<!-- settings grid -->
<div v-show="hasSearchResults" class="columns-1 lg:columns-2 gap-4 space-y-4">
<!-- Banishment -->
<section
v-show="matchesSearch('stranger', 'attachments', 'trust', 'block')"
class="glass-card break-inside-avoid"
>
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Security</div>
<h2>{{ $t("app.stranger_protection") }}</h2>
<p>{{ $t("app.stranger_protection_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<label class="setting-toggle">
<Toggle
id="block-attachments-from-strangers"
v-model="config.block_attachments_from_strangers"
@update:model-value="onStrangerAttachmentBlockChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{
$t("app.block_stranger_attachments")
}}</span>
<span class="setting-toggle__description">{{
$t("app.block_stranger_attachments_description")
}}</span>
</span>
</label>
<label class="setting-toggle">
<Toggle
id="block-all-from-strangers"
v-model="config.block_all_from_strangers"
@update:model-value="onBlockAllFromStrangersChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.block_all_from_strangers") }}</span>
<span class="setting-toggle__description">{{
$t("app.block_all_from_strangers_description")
}}</span>
</span>
</label>
</div>
</section>
<section
v-show="matchesSearch(...sectionKeywords.banishment)"
class="glass-card break-inside-avoid"
@@ -1630,6 +1673,8 @@ export default {
lxmf_preferred_propagation_node_auto_select: null,
archives_max_storage_gb: 1,
backup_max_count: 5,
block_attachments_from_strangers: true,
block_all_from_strangers: false,
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
@@ -2147,6 +2192,14 @@ export default {
);
}, 1000);
},
async onStrangerAttachmentBlockChange(value) {
this.config.block_attachments_from_strangers = value;
await this.updateConfig({ block_attachments_from_strangers: value }, "stranger_protection");
},
async onBlockAllFromStrangersChange(value) {
this.config.block_all_from_strangers = value;
await this.updateConfig({ block_all_from_strangers: value }, "stranger_protection");
},
async onBanishedEffectEnabledChange(value) {
this.config.banished_effect_enabled = value;
await this.updateConfig(