feat(contacts): implement contact management features including add, update, delete, and search functionality; enhance UI with contact sharing capabilities and integrate with existing telephone features

This commit is contained in:
2026-01-01 20:40:50 -06:00
parent bf94ceebbb
commit ea8ef555c2
14 changed files with 651 additions and 174 deletions

View File

@@ -1,14 +1,15 @@
from .announces import AnnounceDAO
from .config import ConfigDAO
from .contacts import ContactsDAO
from .legacy_migrator import LegacyMigrator
from .messages import MessageDAO
from .misc import MiscDAO
from .provider import DatabaseProvider
from .ringtones import RingtoneDAO
from .schema import DatabaseSchema
from .telemetry import TelemetryDAO
from .telephone import TelephoneDAO
from .voicemails import VoicemailDAO
from .ringtones import RingtoneDAO
class Database:
@@ -23,6 +24,7 @@ class Database:
self.telemetry = TelemetryDAO(self.provider)
self.voicemails = VoicemailDAO(self.provider)
self.ringtones = RingtoneDAO(self.provider)
self.contacts = ContactsDAO(self.provider)
def initialize(self):
self.schema.initialize()

View File

@@ -18,13 +18,13 @@ class ConfigDAO:
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))
else:
now = datetime.now(UTC)
# handle booleans specifically to ensure they are stored as "true"/"false"
if isinstance(value, bool):
value_str = "true" if value else "false"
else:
value_str = str(value)
self.provider.execute(
"""
INSERT INTO config (key, value, created_at, updated_at)

View File

@@ -0,0 +1,60 @@
from .provider import DatabaseProvider
class ContactsDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def add_contact(self, name, remote_identity_hash):
self.provider.execute(
"""
INSERT INTO contacts (name, remote_identity_hash)
VALUES (?, ?)
ON CONFLICT(remote_identity_hash) DO UPDATE SET
name = EXCLUDED.name,
updated_at = CURRENT_TIMESTAMP
""",
(name, remote_identity_hash),
)
def get_contacts(self, search=None, limit=100, offset=0):
if search:
return self.provider.fetchall(
"""
SELECT * FROM contacts
WHERE name LIKE ? OR remote_identity_hash LIKE ?
ORDER BY name ASC LIMIT ? OFFSET ?
""",
(f"%{search}%", f"%{search}%", limit, offset),
)
return self.provider.fetchall(
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
(limit, offset),
)
def get_contact(self, contact_id):
return self.provider.fetchone(
"SELECT * FROM contacts WHERE id = ?",
(contact_id,),
)
def update_contact(self, contact_id, name=None, remote_identity_hash=None):
if name and remote_identity_hash:
self.provider.execute(
"UPDATE contacts SET name = ?, remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(name, remote_identity_hash, contact_id),
)
elif name:
self.provider.execute(
"UPDATE contacts SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(name, contact_id),
)
elif remote_identity_hash:
self.provider.execute(
"UPDATE contacts SET remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(remote_identity_hash, contact_id),
)
def delete_contact(self, contact_id):
self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,))

View File

@@ -1,6 +1,8 @@
from datetime import UTC, datetime
from .provider import DatabaseProvider
class RingtoneDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -18,14 +20,14 @@ class RingtoneDAO:
now = datetime.now(UTC)
if display_name is None:
display_name = filename
# check if this is the first ringtone, if so make it primary
count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")["count"]
is_primary = 1 if count == 0 else 0
cursor = self.provider.execute(
"INSERT INTO ringtones (filename, display_name, storage_filename, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(filename, display_name, storage_filename, is_primary, now, now)
(filename, display_name, storage_filename, is_primary, now, now),
)
return cursor.lastrowid
@@ -34,21 +36,21 @@ class RingtoneDAO:
if is_primary == 1:
# reset others
self.provider.execute("UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,))
if display_name is not None and is_primary is not None:
self.provider.execute(
"UPDATE ringtones SET display_name = ?, is_primary = ?, updated_at = ? WHERE id = ?",
(display_name, is_primary, now, ringtone_id)
(display_name, is_primary, now, ringtone_id),
)
elif display_name is not None:
self.provider.execute(
"UPDATE ringtones SET display_name = ?, updated_at = ? WHERE id = ?",
(display_name, now, ringtone_id)
(display_name, now, ringtone_id),
)
elif is_primary is not None:
self.provider.execute(
"UPDATE ringtones SET is_primary = ?, updated_at = ? WHERE id = ?",
(is_primary, now, ringtone_id)
(is_primary, now, ringtone_id),
)
def delete(self, ringtone_id):

View File

@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
LATEST_VERSION = 17
LATEST_VERSION = 18
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -238,6 +238,15 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"contacts": """
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
remote_identity_hash TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
}
for table_name, create_sql in tables.items():
@@ -525,6 +534,23 @@ class DatabaseSchema:
)
""")
if current_version < 18:
self.provider.execute("""
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
remote_identity_hash TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)",
)
self.provider.execute(
"CREATE INDEX IF NOT EXISTS idx_contacts_remote_identity_hash ON contacts(remote_identity_hash)",
)
# Update version in config
self.provider.execute(
"""

View File

@@ -9,7 +9,7 @@ class TelemetryDAO:
self.provider = provider
def upsert_telemetry(
self, destination_hash, timestamp, data, received_from=None, physical_link=None
self, destination_hash, timestamp, data, received_from=None, physical_link=None,
):
now = datetime.now(UTC).isoformat()

View File

@@ -37,7 +37,16 @@ class VoicemailDAO:
),
)
def get_voicemails(self, limit=50, offset=0):
def get_voicemails(self, search=None, limit=50, offset=0):
if search:
return self.provider.fetchall(
"""
SELECT * FROM voicemails
WHERE remote_identity_name LIKE ? OR remote_identity_hash LIKE ?
ORDER BY timestamp DESC LIMIT ? OFFSET ?
""",
(f"%{search}%", f"%{search}%", limit, offset),
)
return self.provider.fetchall(
"SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset),

View File

@@ -1,20 +1,22 @@
import os
import shutil
import subprocess
import RNS
class RingtoneManager:
def __init__(self, config, storage_dir):
self.config = config
self.storage_dir = os.path.join(storage_dir, "ringtones")
# Ensure directory exists
os.makedirs(self.storage_dir, exist_ok=True)
# Paths to executables
self.ffmpeg_path = self._find_ffmpeg()
self.has_ffmpeg = self.ffmpeg_path is not None
if self.has_ffmpeg:
RNS.log(f"Ringtone: Found ffmpeg at {self.ffmpeg_path}", RNS.LOG_DEBUG)
else:

View File

@@ -325,6 +325,10 @@ class VoicemailManager:
try:
self.recording_sink = OpusFileSink(filepath)
# Ensure samplerate is set to avoid TypeError in LXST Opus codec
# which expects sink to have a valid samplerate attribute
self.recording_sink.samplerate = 48000
# Connect the caller's audio source to our sink
# active_call.audio_source is a LinkSource that feeds into receive_mixer
# We want to record what we receive.

View File

@@ -129,12 +129,12 @@
</div>
<!-- Controls -->
<div v-if="!isEnded && !wasDeclined" class="flex flex-wrap justify-center gap-3">
<div v-if="!isEnded && !wasDeclined" class="flex flex-wrap justify-center gap-2 px-2">
<!-- Mute Mic -->
<button
type="button"
:title="isMicMuted ? $t('call.unmute_mic') : $t('call.mute_mic')"
class="p-3 rounded-full transition-all duration-200"
class="p-2.5 rounded-full transition-all duration-200"
:class="
isMicMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
@@ -142,14 +142,14 @@
"
@click="toggleMicrophone"
>
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-6" />
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-5" />
</button>
<!-- Mute Speaker -->
<button
type="button"
:title="isSpeakerMuted ? $t('call.unmute_speaker') : $t('call.mute_speaker')"
class="p-3 rounded-full transition-all duration-200"
class="p-2.5 rounded-full transition-all duration-200"
:class="
isSpeakerMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
@@ -157,7 +157,7 @@
"
@click="toggleSpeaker"
>
<MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-6" />
<MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-5" />
</button>
<!-- Hangup -->
@@ -168,10 +168,10 @@
? $t('call.decline_call')
: $t('call.hangup_call')
"
class="p-3 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
class="p-2.5 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
<MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
</button>
<!-- Send to Voicemail (if incoming) -->
@@ -179,10 +179,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.send_to_voicemail')"
class="p-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
class="p-2.5 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
@click="sendToVoicemail"
>
<MaterialDesignIcon icon-name="voicemail" class="size-6" />
<MaterialDesignIcon icon-name="voicemail" class="size-5" />
</button>
<!-- Answer (if incoming) -->
@@ -190,10 +190,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.answer_call')"
class="p-3 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
class="p-2.5 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
<MaterialDesignIcon icon-name="phone" class="size-5" />
</button>
</div>
</div>
@@ -271,6 +271,7 @@ export default {
default: false,
},
},
emits: ["hangup"],
data() {
return {
isMinimized: false,

View File

@@ -31,6 +31,17 @@
>{{ unreadVoicemailsCount }}</span
>
</button>
<button
:class="[
activeTab === 'contacts'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
]"
class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
@click="activeTab = 'contacts'"
>
Contacts
</button>
<button
:class="[
activeTab === 'ringtone'
@@ -215,16 +226,16 @@
</div>
<!-- actions -->
<div v-if="activeCall" class="flex flex-wrap justify-center gap-4 mt-6">
<div v-if="activeCall" class="flex flex-wrap justify-center gap-4 mt-8 mb-4">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
:title="$t('call.answer_call')"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-5" />
<MaterialDesignIcon icon-name="phone" class="size-4" />
<span>{{ $t("call.accept") }}</span>
</button>
@@ -233,10 +244,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4"
:title="$t('call.send_to_voicemail')"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200"
class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200"
@click="sendToVoicemail"
>
<MaterialDesignIcon icon-name="voicemail" class="size-5" />
<MaterialDesignIcon icon-name="voicemail" class="size-4" />
<span>{{ $t("call.send_to_voicemail") }}</span>
</button>
@@ -248,10 +259,10 @@
: $t('call.hangup_call')
"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
<MaterialDesignIcon icon-name="phone-hangup" class="size-4 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4
? $t("call.decline")
@@ -403,6 +414,21 @@
<!-- Voicemail Tab -->
<div v-if="activeTab === 'voicemail'" class="flex-1 flex flex-col">
<div class="mb-4">
<div class="relative">
<input
v-model="voicemailSearch"
type="text"
placeholder="Search voicemails..."
class="block w-full rounded-lg border-0 py-2 pl-10 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"
@input="onVoicemailSearchInput"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
</div>
</div>
<div v-if="voicemails.length === 0" class="my-auto text-center">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
<MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
@@ -530,6 +556,114 @@
</div>
</div>
<!-- Contacts Tab -->
<div v-if="activeTab === 'contacts'" class="flex-1 flex flex-col">
<div class="mb-4 flex gap-2">
<div class="relative flex-1">
<input
v-model="contactsSearch"
type="text"
placeholder="Search contacts..."
class="block w-full rounded-lg border-0 py-2 pl-10 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"
@input="onContactsSearchInput"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
</div>
<button
type="button"
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-colors flex items-center gap-2"
@click="openAddContactModal"
>
<MaterialDesignIcon icon-name="plus" class="size-5" />
Add
</button>
</div>
<div v-if="contacts.length === 0" class="my-auto text-center">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
<MaterialDesignIcon icon-name="account-multiple" class="size-12 text-gray-400" />
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No Contacts</h3>
<p class="text-gray-500 dark:text-zinc-400">Add contacts to quickly call them.</p>
</div>
<div v-else class="space-y-4">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="contact in contacts"
:key="contact.id"
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
>
<div class="flex items-center space-x-3">
<div class="shrink-0">
<LxmfUserIcon
v-if="contact.remote_icon"
:icon-name="contact.remote_icon.icon_name"
:icon-foreground-colour="contact.remote_icon.foreground_colour"
:icon-background-colour="contact.remote_icon.background_colour"
class="size-10"
/>
<div
v-else
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center"
>
<MaterialDesignIcon icon-name="account" class="size-6" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-gray-900 dark:text-white truncate">
{{ contact.name }}
</p>
<div class="flex items-center gap-1">
<button
type="button"
class="p-1.5 text-gray-400 hover:text-blue-500 transition-colors"
@click="openEditContactModal(contact)"
>
<MaterialDesignIcon icon-name="pencil" class="size-4" />
</button>
<button
type="button"
class="p-1.5 text-gray-400 hover:text-red-500 transition-colors"
@click="deleteContact(contact.id)"
>
<MaterialDesignIcon icon-name="delete" class="size-4" />
</button>
</div>
</div>
<div class="flex items-center justify-between mt-1">
<span
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate"
:title="contact.remote_identity_hash"
>
{{ formatDestinationHash(contact.remote_identity_hash) }}
</span>
<button
type="button"
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
@click="
destinationHash = contact.remote_identity_hash;
activeTab = 'phone';
call(destinationHash);
"
>
Call
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Ringtone Tab -->
<div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6">
<div
@@ -933,6 +1067,16 @@ export default {
editingRingtoneId: null,
editingRingtoneName: "",
elapsedTimeInterval: null,
voicemailSearch: "",
contactsSearch: "",
contacts: [],
isContactModalOpen: false,
editingContact: null,
contactForm: {
name: "",
remote_identity_hash: "",
},
searchDebounceTimeout: null,
};
},
computed: {
@@ -956,6 +1100,7 @@ export default {
this.getStatus();
this.getHistory();
this.getVoicemails();
this.getContacts();
this.getVoicemailStatus();
this.getRingtones();
this.getRingtoneStatus();
@@ -971,6 +1116,7 @@ export default {
this.historyInterval = setInterval(() => {
this.getHistory();
this.getVoicemails();
this.getContacts();
}, 10000);
// update elapsed time every second
@@ -1204,13 +1350,78 @@ export default {
},
async getVoicemails() {
try {
const response = await window.axios.get("/api/v1/telephone/voicemails");
const response = await window.axios.get("/api/v1/telephone/voicemails", {
params: { search: this.voicemailSearch },
});
this.voicemails = response.data.voicemails;
this.unreadVoicemailsCount = response.data.unread_count;
} catch (e) {
console.log(e);
}
},
onVoicemailSearchInput() {
if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
this.searchDebounceTimeout = setTimeout(() => {
this.getVoicemails();
}, 300);
},
async getContacts() {
try {
const response = await window.axios.get("/api/v1/telephone/contacts", {
params: { search: this.contactsSearch },
});
this.contacts = response.data;
} catch (e) {
console.log(e);
}
},
onContactsSearchInput() {
if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
this.searchDebounceTimeout = setTimeout(() => {
this.getContacts();
}, 300);
},
openAddContactModal() {
this.editingContact = null;
this.contactForm = { name: "", remote_identity_hash: "" };
const name = prompt("Enter contact name:");
if (!name) return;
const hash = prompt("Enter identity hash:");
if (!hash) return;
this.saveContact({ name, remote_identity_hash: hash });
},
openEditContactModal(contact) {
this.editingContact = contact;
const name = prompt("Edit contact name:", contact.name);
if (!name) return;
const hash = prompt("Edit identity hash:", contact.remote_identity_hash);
if (!hash) return;
this.saveContact({ id: contact.id, name, remote_identity_hash: hash });
},
async saveContact(contact) {
try {
if (contact.id) {
await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, contact);
ToastUtils.success("Contact updated");
} else {
await window.axios.post("/api/v1/telephone/contacts", contact);
ToastUtils.success("Contact added");
}
this.getContacts();
} catch (e) {
ToastUtils.error(e.response?.data?.message || "Failed to save contact");
}
},
async deleteContact(contactId) {
if (!confirm("Are you sure you want to delete this contact?")) return;
try {
await window.axios.delete(`/api/v1/telephone/contacts/${contactId}`);
ToastUtils.success("Contact deleted");
this.getContacts();
} catch {
ToastUtils.error("Failed to delete contact");
}
},
async generateGreeting() {
this.isGeneratingGreeting = true;
try {
@@ -1259,7 +1470,9 @@ export default {
},
async playVoicemail(voicemail) {
if (this.playingVoicemailId === voicemail.id) {
this.audioPlayer.pause();
if (this.audioPlayer) {
this.audioPlayer.pause();
}
this.playingVoicemailId = null;
return;
}
@@ -1270,11 +1483,25 @@ export default {
this.playingVoicemailId = voicemail.id;
this.audioPlayer = new Audio(`/api/v1/telephone/voicemails/${voicemail.id}/audio`);
this.audioPlayer.play();
this.audioPlayer.addEventListener("error", (e) => {
console.error("Audio player error:", e);
ToastUtils.error(this.$t("call.failed_to_play_voicemail") || "Failed to load voicemail audio");
this.playingVoicemailId = null;
this.audioPlayer = null;
});
this.audioPlayer.onended = () => {
this.playingVoicemailId = null;
};
try {
await this.audioPlayer.play();
} catch (e) {
console.error("Audio play failed:", e);
this.playingVoicemailId = null;
}
// Mark as read
if (!voicemail.is_read) {
try {

View File

@@ -108,6 +108,11 @@
<MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4" />
</IconButton>
<!-- share contact button -->
<IconButton title="Share Contact" @click="openShareContactModal">
<MaterialDesignIcon icon-name="notebook-outline" class="w-4 h-4" />
</IconButton>
<!-- close button -->
<IconButton title="Close" @click="close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
@@ -119,6 +124,64 @@
</div>
</div>
<!-- Share Contact Modal -->
<div
v-if="isShareContactModalOpen"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="isShareContactModalOpen = false"
>
<div class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
<div class="px-6 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-white">Share Contact</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
@click="isShareContactModalOpen = false"
>
<MaterialDesignIcon icon-name="close" class="size-6" />
</button>
</div>
<div class="p-6">
<div class="mb-4">
<div class="relative">
<input
v-model="contactsSearch"
type="text"
placeholder="Search contacts..."
class="block w-full rounded-lg border-0 py-2 pl-10 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"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
</div>
</div>
<div class="max-h-64 overflow-y-auto space-y-2">
<button
v-for="contact in filteredContacts"
:key="contact.id"
type="button"
class="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
@click="shareContact(contact)"
>
<div
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center shrink-0"
>
<MaterialDesignIcon icon-name="account" class="size-6" />
</div>
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white truncate">
{{ contact.name }}
</div>
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
{{ contact.remote_identity_hash }}
</div>
</div>
</button>
</div>
</div>
</div>
</div>
<!-- chat items -->
<div
id="messages"
@@ -885,6 +948,10 @@ export default {
autoScrollOnNewMessage: true,
composeAddress: "",
isShareContactModalOpen: false,
contacts: [],
contactsSearch: "",
isRecordingAudioAttachment: false,
audioAttachmentMicrophoneRecorder: null,
audioAttachmentMicrophoneRecorderCodec: null,
@@ -912,6 +979,13 @@ export default {
};
},
computed: {
filteredContacts() {
if (!this.contactsSearch) return this.contacts;
const s = this.contactsSearch.toLowerCase();
return this.contacts.filter(
(c) => c.name.toLowerCase().includes(s) || c.remote_identity_hash.toLowerCase().includes(s)
);
},
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
},
@@ -1612,6 +1686,27 @@ export default {
// do nothing if failed to delete message
}
},
async openShareContactModal() {
try {
const response = await window.axios.get("/api/v1/telephone/contacts");
this.contacts = response.data;
if (this.contacts.length === 0) {
ToastUtils.info("No contacts found in telephone");
return;
}
this.isShareContactModalOpen = true;
} catch (e) {
console.error(e);
ToastUtils.error("Failed to load contacts");
}
},
async shareContact(contact) {
this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
this.isShareContactModalOpen = false;
await this.sendMessage();
},
async sendMessage() {
// do nothing if can't send message
if (!this.canSendMessage) {

View File

@@ -3,4 +3,4 @@ Auto-generated helper so Python tooling and the Electron build
share the same version string.
"""
__version__ = '3.0.0'
__version__ = '3.1.0'