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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
60
meshchatx/src/backend/database/contacts.py
Normal file
60
meshchatx/src/backend/database/contacts.py
Normal 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,))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user