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

@@ -57,13 +57,13 @@ from meshchatx.src.backend.lxmf_message_fields import (
)
from meshchatx.src.backend.map_manager import MapManager
from meshchatx.src.backend.message_handler import MessageHandler
from meshchatx.src.backend.ringtone_manager import RingtoneManager
from meshchatx.src.backend.rncp_handler import RNCPHandler
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.sideband_commands import SidebandCommands
from meshchatx.src.backend.telemetry_utils import Telemeter
from meshchatx.src.backend.telephone_manager import TelephoneManager
from meshchatx.src.backend.ringtone_manager import RingtoneManager
from meshchatx.src.backend.translator_handler import TranslatorHandler
from meshchatx.src.backend.voicemail_manager import VoicemailManager
from meshchatx.src.version import __version__ as app_version
@@ -186,13 +186,13 @@ class ReticulumMeshChat:
self.auto_recover = auto_recover
self.auth_enabled_initial = auth_enabled
self.websocket_clients: list[web.WebSocketResponse] = []
# track announce timestamps for rate calculation
self.announce_timestamps = []
# track download speeds for nomadnetwork files
self.download_speeds = []
# track active downloads
self.active_downloads = {}
self.download_id_counter = 0
@@ -201,7 +201,7 @@ class ReticulumMeshChat:
def setup_identity(self, identity: RNS.Identity):
# assign a unique session ID to this identity instance to help background threads exit
if not hasattr(self, '_identity_session_id'):
if not hasattr(self, "_identity_session_id"):
self._identity_session_id = 0
self._identity_session_id += 1
session_id = self._identity_session_id
@@ -215,8 +215,8 @@ class ReticulumMeshChat:
print(f"Using Storage Path: {self.storage_path}")
os.makedirs(self.storage_path, exist_ok=True)
# Safety: Before setting up a new identity, ensure no destinations for this identity
# are currently registered in Transport. This prevents the "Attempt to register
# Safety: Before setting up a new identity, ensure no destinations for this identity
# are currently registered in Transport. This prevents the "Attempt to register
# an already registered destination" error during hotswap.
self.cleanup_rns_state_for_identity(identity.hash)
@@ -275,7 +275,7 @@ class ReticulumMeshChat:
self.database.messages.mark_stuck_messages_as_failed()
# init reticulum
if not hasattr(self, 'reticulum'):
if not hasattr(self, "reticulum"):
self.reticulum = RNS.Reticulum(self.reticulum_config_dir)
self.identity = identity
@@ -606,7 +606,7 @@ class ReticulumMeshChat:
def cleanup_rns_state_for_identity(self, identity_hash):
if not identity_hash:
return
if isinstance(identity_hash, str):
identity_hash_bytes = bytes.fromhex(identity_hash)
identity_hash_hex = identity_hash
@@ -622,10 +622,10 @@ class ReticulumMeshChat:
for destination in list(RNS.Transport.destinations):
match = False
# check identity hash
if hasattr(destination, 'identity') and destination.identity:
if hasattr(destination, "identity") and destination.identity:
if destination.identity.hash == identity_hash_bytes:
match = True
if match:
print(f"Deregistering RNS destination {destination} ({RNS.prettyhexrep(destination.hash)})")
RNS.Transport.deregister_destination(destination)
@@ -637,11 +637,11 @@ class ReticulumMeshChat:
for link in list(RNS.Transport.active_links):
match = False
# check if local identity or destination matches
if hasattr(link, 'destination') and link.destination:
if hasattr(link.destination, 'identity') and link.destination.identity:
if hasattr(link, "destination") and link.destination:
if hasattr(link.destination, "identity") and link.destination.identity:
if link.destination.identity.hash == identity_hash_bytes:
match = True
if match:
print(f"Tearing down RNS link {link}")
try:
@@ -654,27 +654,27 @@ class ReticulumMeshChat:
def teardown_identity(self):
print("Tearing down current identity instance...")
self.running = False
# 1. Deregister destinations and links from RNS Transport
try:
# Get current identity hash for matching
current_identity_hash = self.identity.hash if hasattr(self, 'identity') and self.identity else None
current_identity_hash = self.identity.hash if hasattr(self, "identity") and self.identity else None
# Explicitly deregister known destinations from managers first
if hasattr(self, 'message_router') and self.message_router:
if hasattr(self, "message_router") and self.message_router:
# Deregister delivery destinations
if hasattr(self.message_router, 'delivery_destinations'):
if hasattr(self.message_router, "delivery_destinations"):
for dest_hash in list(self.message_router.delivery_destinations.keys()):
dest = self.message_router.delivery_destinations[dest_hash]
RNS.Transport.deregister_destination(dest)
# Deregister propagation destination
if hasattr(self.message_router, 'propagation_destination') and self.message_router.propagation_destination:
if hasattr(self.message_router, "propagation_destination") and self.message_router.propagation_destination:
RNS.Transport.deregister_destination(self.message_router.propagation_destination)
if hasattr(self, 'telephone_manager') and self.telephone_manager:
if hasattr(self.telephone_manager, 'telephone') and self.telephone_manager.telephone:
if hasattr(self.telephone_manager.telephone, 'destination') and self.telephone_manager.telephone.destination:
if hasattr(self, "telephone_manager") and self.telephone_manager:
if hasattr(self.telephone_manager, "telephone") and self.telephone_manager.telephone:
if hasattr(self.telephone_manager.telephone, "destination") and self.telephone_manager.telephone.destination:
RNS.Transport.deregister_destination(self.telephone_manager.telephone.destination)
# Use the global helper for thorough cleanup
@@ -688,53 +688,47 @@ class ReticulumMeshChat:
try:
for handler in list(RNS.Transport.announce_handlers):
should_deregister = False
# check if it's one of our AnnounceHandler instances
if hasattr(handler, 'aspect_filter') and hasattr(handler, 'received_announce_callback'):
if (hasattr(handler, "aspect_filter") and hasattr(handler, "received_announce_callback")) or (hasattr(handler, "router") and hasattr(self, "message_router") and handler.router == self.message_router) or "LXMFDeliveryAnnounceHandler" in str(type(handler)) or "LXMFPropagationAnnounceHandler" in str(type(handler)):
should_deregister = True
# LXMF handlers - they usually have a reference to the router
elif hasattr(handler, 'router') and hasattr(self, 'message_router') and handler.router == self.message_router:
should_deregister = True
# generic check for LXMF handlers if the above fails
elif "LXMFDeliveryAnnounceHandler" in str(type(handler)) or "LXMFPropagationAnnounceHandler" in str(type(handler)):
should_deregister = True
if should_deregister:
RNS.Transport.deregister_announce_handler(handler)
except Exception as e:
print(f"Error while deregistering announce handlers: {e}")
# 3. Stop the LXMRouter job loop (hacking it to stop)
if hasattr(self, 'message_router') and self.message_router:
if hasattr(self, "message_router") and self.message_router:
try:
# Replacing jobs with a no-op so the thread just sleeps
self.message_router.jobs = lambda: None
# Try to call exit_handler to persist state
if hasattr(self.message_router, 'exit_handler'):
if hasattr(self.message_router, "exit_handler"):
self.message_router.exit_handler()
except Exception as e:
print(f"Error while tearing down LXMRouter: {e}")
# 4. Stop telephone and voicemail
if hasattr(self, 'telephone_manager') and self.telephone_manager:
if hasattr(self, "telephone_manager") and self.telephone_manager:
try:
# use teardown instead of shutdown
if hasattr(self.telephone_manager, 'teardown'):
if hasattr(self.telephone_manager, "teardown"):
self.telephone_manager.teardown()
elif hasattr(self.telephone_manager, 'shutdown'):
elif hasattr(self.telephone_manager, "shutdown"):
self.telephone_manager.shutdown()
except Exception as e:
print(f"Error while tearing down telephone: {e}")
if hasattr(self, 'voicemail_manager') and self.voicemail_manager:
if hasattr(self, "voicemail_manager") and self.voicemail_manager:
try:
self.voicemail_manager.stop_recording()
except Exception:
pass
# 5. Close database
if hasattr(self, 'database') and self.database:
if hasattr(self, "database") and self.database:
try:
self.database.close()
except Exception:
@@ -747,33 +741,33 @@ class ReticulumMeshChat:
identity_file = os.path.join(identity_dir, "identity")
if not os.path.exists(identity_file):
raise ValueError("Identity file not found")
new_identity = RNS.Identity.from_file(identity_file)
# 1. teardown old identity
self.teardown_identity()
# Wait a moment for threads to notice self.running=False and destinations to clear
await asyncio.sleep(3)
# 2. update main identity file
main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity")
import shutil
shutil.copy2(identity_file, main_identity_file)
# 3. reset state and setup new identity
self.running = True
self.setup_identity(new_identity)
# 4. broadcast update to clients
await self.websocket_broadcast(
json.dumps({
"type": "identity_switched",
"identity_hash": identity_hash,
"display_name": self.config.display_name.get()
})
"display_name": self.config.display_name.get(),
}),
)
return True
except Exception as e:
print(f"Hotswap failed: {e}")
@@ -818,12 +812,12 @@ class ReticulumMeshChat:
icon_name = None
icon_foreground_colour = None
icon_background_colour = None
try:
# use a temporary provider to avoid messing with current DB
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.config import ConfigDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
temp_provider = DatabaseProvider(db_path)
temp_config_dao = ConfigDAO(temp_provider)
display_name = temp_config_dao.get("display_name", "Anonymous Peer")
@@ -840,49 +834,49 @@ class ReticulumMeshChat:
"icon_name": icon_name,
"icon_foreground_colour": icon_foreground_colour,
"icon_background_colour": icon_background_colour,
"is_current": identity_hash == self.identity.hash.hex()
"is_current": identity_hash == self.identity.hash.hex(),
})
return identities
def create_identity(self, display_name=None):
new_identity = RNS.Identity(create_keys=True)
identity_hash = new_identity.hash.hex()
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
os.makedirs(identity_dir, exist_ok=True)
# save identity file in its own directory
identity_file = os.path.join(identity_dir, "identity")
with open(identity_file, "wb") as f:
f.write(new_identity.get_private_key())
# initialize its database and set display name
db_path = os.path.join(identity_dir, "database.db")
# Avoid using the Database class singleton behavior
from meshchatx.src.backend.database.config import ConfigDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
from meshchatx.src.backend.database.config import ConfigDAO
new_provider = DatabaseProvider(db_path)
new_schema = DatabaseSchema(new_provider)
new_schema.initialize()
if display_name:
new_config_dao = ConfigDAO(new_provider)
new_config_dao.set("display_name", display_name)
new_provider.close()
return {
"hash": identity_hash,
"display_name": display_name or "Anonymous Peer"
"display_name": display_name or "Anonymous Peer",
}
def delete_identity(self, identity_hash):
if identity_hash == self.identity.hash.hex():
raise ValueError("Cannot delete the current active identity")
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
if os.path.exists(identity_dir):
import shutil
@@ -1454,10 +1448,10 @@ class ReticulumMeshChat:
# session secret for encrypted cookies (generate once and store in shared storage)
session_secret_path = os.path.join(self.storage_dir, "session_secret")
self.session_secret_key = None
if os.path.exists(session_secret_path):
try:
with open(session_secret_path, "r") as f:
with open(session_secret_path) as f:
self.session_secret_key = f.read().strip()
except Exception as e:
print(f"Failed to read session secret from {session_secret_path}: {e}")
@@ -1467,7 +1461,7 @@ class ReticulumMeshChat:
self.session_secret_key = self.config.auth_session_secret.get()
if not self.session_secret_key:
self.session_secret_key = secrets.token_urlsafe(32)
try:
with open(session_secret_path, "w") as f:
f.write(self.session_secret_key)
@@ -1529,7 +1523,7 @@ class ReticulumMeshChat:
is_authenticated = session.get("authenticated", False)
session_identity = session.get("identity_hash")
# Check if authenticated AND matches current identity
if not is_authenticated or session_identity != self.identity.hash.hex():
if path.startswith("/api/"):
@@ -1572,10 +1566,10 @@ class ReticulumMeshChat:
session = await get_session(request)
is_authenticated = session.get("authenticated", False)
session_identity = session.get("identity_hash")
# Verify that authentication is for the CURRENT active identity
actually_authenticated = is_authenticated and (session_identity == self.identity.hash.hex())
return web.json_response(
{
"auth_enabled": self.auth_enabled,
@@ -2858,13 +2852,12 @@ class ReticulumMeshChat:
"message": "Identity deleted successfully",
},
)
else:
return web.json_response(
{
"message": "Identity not found",
},
status=404,
)
return web.json_response(
{
"message": "Identity not found",
},
status=404,
)
except Exception as e:
return web.json_response(
{
@@ -2878,10 +2871,10 @@ class ReticulumMeshChat:
try:
data = await request.json()
identity_hash = data.get("identity_hash")
# attempt hotswap first
success = await self.hotswap_identity(identity_hash)
if success:
return web.json_response(
{
@@ -2889,36 +2882,35 @@ class ReticulumMeshChat:
"hotswapped": True,
},
)
else:
# fallback to restart if hotswap failed
# (this part should probably be unreachable if hotswap is reliable)
main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity")
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
identity_file = os.path.join(identity_dir, "identity")
import shutil
shutil.copy2(identity_file, main_identity_file)
def restart():
import sys
import time
import os
time.sleep(1)
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception as e:
print(f"Failed to restart: {e}")
os._exit(0)
import threading
threading.Thread(target=restart).start()
# fallback to restart if hotswap failed
# (this part should probably be unreachable if hotswap is reliable)
main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity")
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
identity_file = os.path.join(identity_dir, "identity")
import shutil
shutil.copy2(identity_file, main_identity_file)
return web.json_response(
{
"message": "Identity switch scheduled. Application will restart.",
"hotswapped": False,
"should_restart": True,
},
)
def restart():
import os
import sys
import time
time.sleep(1)
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception as e:
print(f"Failed to restart: {e}")
os._exit(0)
import threading
threading.Thread(target=restart).start()
return web.json_response(
{
"message": "Identity switch scheduled. Application will restart.",
"hotswapped": False,
"should_restart": True,
},
)
except Exception as e:
return web.json_response(
{
@@ -3147,7 +3139,7 @@ class ReticulumMeshChat:
async def telephone_history(request):
limit = int(request.query.get("limit", 10))
history = self.database.telephone.get_call_history(limit=limit)
call_history = []
for row in history:
d = dict(row)
@@ -3312,9 +3304,11 @@ class ReticulumMeshChat:
# list voicemails
@routes.get("/api/v1/telephone/voicemails")
async def telephone_voicemails(request):
search = request.query.get("search")
limit = int(request.query.get("limit", 50))
offset = int(request.query.get("offset", 0))
voicemails_rows = self.database.voicemails.get_voicemails(
search=search,
limit=limit,
offset=offset,
)
@@ -3365,12 +3359,12 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/voicemail/greeting/audio")
async def telephone_voicemail_greeting_audio(request):
filepath = os.path.join(
self.voicemail_manager.greetings_dir, "greeting.opus"
self.voicemail_manager.greetings_dir, "greeting.opus",
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
return web.json_response(
{"message": "Greeting audio not found"}, status=404
{"message": "Greeting audio not found"}, status=404,
)
# serve voicemail audio
@@ -3385,6 +3379,7 @@ class ReticulumMeshChat:
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
RNS.log(f"Voicemail: Recording file missing for ID {voicemail_id}: {filepath}", RNS.LOG_ERROR)
return web.json_response(
{"message": "Voicemail audio not found"},
status=404,
@@ -3413,7 +3408,7 @@ class ReticulumMeshChat:
field = await reader.next()
if field.name != "file":
return web.json_response(
{"message": "File field required"}, status=400
{"message": "File field required"}, status=400,
)
filename = field.filename
@@ -3472,7 +3467,7 @@ class ReticulumMeshChat:
"created_at": r["created_at"],
}
for r in ringtones
]
],
)
@routes.get("/api/v1/telephone/ringtones/status")
@@ -3484,7 +3479,7 @@ class ReticulumMeshChat:
"enabled": self.config.custom_ringtone_enabled.get(),
"filename": primary["filename"] if primary else None,
"id": primary["id"] if primary else None,
}
},
)
@routes.get("/api/v1/telephone/ringtones/{id}/audio")
@@ -3493,7 +3488,7 @@ class ReticulumMeshChat:
ringtone = self.database.ringtones.get_by_id(ringtone_id)
if not ringtone:
return web.json_response({"message": "Ringtone not found"}, status=404)
filepath = self.ringtone_manager.get_ringtone_path(ringtone["storage_filename"])
if os.path.exists(filepath):
return web.FileResponse(filepath)
@@ -3511,7 +3506,7 @@ class ReticulumMeshChat:
extension = os.path.splitext(filename)[1].lower()
if extension not in [".mp3", ".ogg", ".wav", ".m4a", ".flac"]:
return web.json_response(
{"message": f"Unsupported file type: {extension}"}, status=400
{"message": f"Unsupported file type: {extension}"}, status=400,
)
# Save temp file
@@ -3529,20 +3524,20 @@ class ReticulumMeshChat:
self.ringtone_manager.convert_to_ringtone,
temp_path,
)
# Add to database
ringtone_id = self.database.ringtones.add(
filename=filename,
storage_filename=storage_filename
storage_filename=storage_filename,
)
return web.json_response(
{
"message": "Ringtone uploaded and converted",
"message": "Ringtone uploaded and converted",
"id": ringtone_id,
"filename": filename,
"storage_filename": storage_filename
}
"storage_filename": storage_filename,
},
)
finally:
if os.path.exists(temp_path):
@@ -3556,16 +3551,16 @@ class ReticulumMeshChat:
try:
ringtone_id = int(request.match_info["id"])
data = await request.json()
display_name = data.get("display_name")
is_primary = 1 if data.get("is_primary") else None
self.database.ringtones.update(
ringtone_id,
display_name=display_name,
is_primary=is_primary
is_primary=is_primary,
)
return web.json_response({"message": "Ringtone updated"})
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
@@ -3582,6 +3577,60 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
# contacts routes
@routes.get("/api/v1/telephone/contacts")
async def telephone_contacts_get(request):
search = request.query.get("search")
limit = int(request.query.get("limit", 100))
offset = int(request.query.get("offset", 0))
contacts_rows = self.database.contacts.get_contacts(
search=search,
limit=limit,
offset=offset,
)
contacts = []
for row in contacts_rows:
d = dict(row)
remote_identity_hash = d.get("remote_identity_hash")
if remote_identity_hash:
lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(remote_identity_hash)
if lxmf_hash:
icon = self.database.misc.get_user_icon(lxmf_hash)
if icon:
d["remote_icon"] = dict(icon)
contacts.append(d)
return web.json_response(contacts)
@routes.post("/api/v1/telephone/contacts")
async def telephone_contacts_post(request):
data = await request.json()
name = data.get("name")
remote_identity_hash = data.get("remote_identity_hash")
if not name or not remote_identity_hash:
return web.json_response({"message": "Name and identity hash required"}, status=400)
self.database.contacts.add_contact(name, remote_identity_hash)
return web.json_response({"message": "Contact added"})
@routes.patch("/api/v1/telephone/contacts/{id}")
async def telephone_contacts_patch(request):
contact_id = int(request.match_info["id"])
data = await request.json()
name = data.get("name")
remote_identity_hash = data.get("remote_identity_hash")
self.database.contacts.update_contact(contact_id, name, remote_identity_hash)
return web.json_response({"message": "Contact updated"})
@routes.delete("/api/v1/telephone/contacts/{id}")
async def telephone_contacts_delete(request):
contact_id = int(request.match_info["id"])
self.database.contacts.delete_contact(contact_id)
return web.json_response({"message": "Contact deleted"})
# announce
@routes.get("/api/v1/announce")
async def announce_trigger(request):
@@ -4921,12 +4970,12 @@ class ReticulumMeshChat:
search_query = request.query.get("q", None)
filter_unread = ReticulumMeshChat.parse_bool_query_param(
request.query.get(
"unread", request.query.get("filter_unread", "false")
"unread", request.query.get("filter_unread", "false"),
),
)
filter_failed = ReticulumMeshChat.parse_bool_query_param(
request.query.get(
"failed", request.query.get("filter_failed", "false")
"failed", request.query.get("filter_failed", "false"),
),
)
filter_has_attachments = ReticulumMeshChat.parse_bool_query_param(
@@ -5317,7 +5366,7 @@ class ReticulumMeshChat:
if r["physical_link"]
else None,
"updated_at": r["updated_at"],
}
},
)
return web.json_response({"telemetry": telemetry_list})
@@ -5329,7 +5378,7 @@ class ReticulumMeshChat:
offset = int(request.query.get("offset", 0))
results = self.database.telemetry.get_telemetry_history(
destination_hash, limit, offset
destination_hash, limit, offset,
)
telemetry_list = []
for r in results:
@@ -5343,7 +5392,7 @@ class ReticulumMeshChat:
if r["physical_link"]
else None,
"updated_at": r["updated_at"],
}
},
)
return web.json_response({"telemetry": telemetry_list})
@@ -5365,7 +5414,7 @@ class ReticulumMeshChat:
if r["physical_link"]
else None,
"updated_at": r["updated_at"],
}
},
)
# upload offline map
@@ -5542,7 +5591,7 @@ class ReticulumMeshChat:
# setup session storage
# aiohttp_session.setup must be called before other middlewares that use sessions
# Ensure we have a valid 32-byte key for Fernet
try:
# First try decoding as base64 (since secrets.token_urlsafe produces base64)
@@ -7444,7 +7493,7 @@ class ReticulumMeshChat:
if lat is None or lon is None:
print(
f"Cannot respond to telemetry request from {to_addr_hash}: No location set"
f"Cannot respond to telemetry request from {to_addr_hash}: No location set",
)
return

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'