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

View File

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

View File

@@ -18,13 +18,13 @@ class ConfigDAO:
self.provider.execute("DELETE FROM config WHERE key = ?", (key,)) self.provider.execute("DELETE FROM config WHERE key = ?", (key,))
else: else:
now = datetime.now(UTC) now = datetime.now(UTC)
# handle booleans specifically to ensure they are stored as "true"/"false" # handle booleans specifically to ensure they are stored as "true"/"false"
if isinstance(value, bool): if isinstance(value, bool):
value_str = "true" if value else "false" value_str = "true" if value else "false"
else: else:
value_str = str(value) value_str = str(value)
self.provider.execute( self.provider.execute(
""" """
INSERT INTO config (key, value, created_at, updated_at) 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 datetime import UTC, datetime
from .provider import DatabaseProvider from .provider import DatabaseProvider
class RingtoneDAO: class RingtoneDAO:
def __init__(self, provider: DatabaseProvider): def __init__(self, provider: DatabaseProvider):
self.provider = provider self.provider = provider
@@ -18,14 +20,14 @@ class RingtoneDAO:
now = datetime.now(UTC) now = datetime.now(UTC)
if display_name is None: if display_name is None:
display_name = filename display_name = filename
# check if this is the first ringtone, if so make it primary # check if this is the first ringtone, if so make it primary
count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")["count"] count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")["count"]
is_primary = 1 if count == 0 else 0 is_primary = 1 if count == 0 else 0
cursor = self.provider.execute( cursor = self.provider.execute(
"INSERT INTO ringtones (filename, display_name, storage_filename, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", "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 return cursor.lastrowid
@@ -34,21 +36,21 @@ class RingtoneDAO:
if is_primary == 1: if is_primary == 1:
# reset others # reset others
self.provider.execute("UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,)) self.provider.execute("UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,))
if display_name is not None and is_primary is not None: if display_name is not None and is_primary is not None:
self.provider.execute( self.provider.execute(
"UPDATE ringtones SET display_name = ?, is_primary = ?, updated_at = ? WHERE id = ?", "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: elif display_name is not None:
self.provider.execute( self.provider.execute(
"UPDATE ringtones SET display_name = ?, updated_at = ? WHERE id = ?", "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: elif is_primary is not None:
self.provider.execute( self.provider.execute(
"UPDATE ringtones SET is_primary = ?, updated_at = ? WHERE id = ?", "UPDATE ringtones SET is_primary = ?, updated_at = ? WHERE id = ?",
(is_primary, now, ringtone_id) (is_primary, now, ringtone_id),
) )
def delete(self, ringtone_id): def delete(self, ringtone_id):

View File

@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema: class DatabaseSchema:
LATEST_VERSION = 17 LATEST_VERSION = 18
def __init__(self, provider: DatabaseProvider): def __init__(self, provider: DatabaseProvider):
self.provider = provider self.provider = provider
@@ -238,6 +238,15 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 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(): 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 # Update version in config
self.provider.execute( self.provider.execute(
""" """

View File

@@ -9,7 +9,7 @@ class TelemetryDAO:
self.provider = provider self.provider = provider
def upsert_telemetry( 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() 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( return self.provider.fetchall(
"SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?", "SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset), (limit, offset),

View File

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

View File

@@ -325,6 +325,10 @@ class VoicemailManager:
try: try:
self.recording_sink = OpusFileSink(filepath) 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 # Connect the caller's audio source to our sink
# active_call.audio_source is a LinkSource that feeds into receive_mixer # active_call.audio_source is a LinkSource that feeds into receive_mixer
# We want to record what we receive. # We want to record what we receive.

View File

@@ -129,12 +129,12 @@
</div> </div>
<!-- Controls --> <!-- 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 --> <!-- Mute Mic -->
<button <button
type="button" type="button"
:title="isMicMuted ? $t('call.unmute_mic') : $t('call.mute_mic')" :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=" :class="
isMicMuted isMicMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30' ? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
@@ -142,14 +142,14 @@
" "
@click="toggleMicrophone" @click="toggleMicrophone"
> >
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-6" /> <MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-5" />
</button> </button>
<!-- Mute Speaker --> <!-- Mute Speaker -->
<button <button
type="button" type="button"
:title="isSpeakerMuted ? $t('call.unmute_speaker') : $t('call.mute_speaker')" :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=" :class="
isSpeakerMuted isSpeakerMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30' ? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
@@ -157,7 +157,7 @@
" "
@click="toggleSpeaker" @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> </button>
<!-- Hangup --> <!-- Hangup -->
@@ -168,10 +168,10 @@
? $t('call.decline_call') ? $t('call.decline_call')
: $t('call.hangup_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" @click="hangupCall"
> >
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" /> <MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
</button> </button>
<!-- Send to Voicemail (if incoming) --> <!-- Send to Voicemail (if incoming) -->
@@ -179,10 +179,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4" v-if="activeCall.is_incoming && activeCall.status === 4"
type="button" type="button"
:title="$t('call.send_to_voicemail')" :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" @click="sendToVoicemail"
> >
<MaterialDesignIcon icon-name="voicemail" class="size-6" /> <MaterialDesignIcon icon-name="voicemail" class="size-5" />
</button> </button>
<!-- Answer (if incoming) --> <!-- Answer (if incoming) -->
@@ -190,10 +190,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4" v-if="activeCall.is_incoming && activeCall.status === 4"
type="button" type="button"
:title="$t('call.answer_call')" :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" @click="answerCall"
> >
<MaterialDesignIcon icon-name="phone" class="size-6" /> <MaterialDesignIcon icon-name="phone" class="size-5" />
</button> </button>
</div> </div>
</div> </div>
@@ -271,6 +271,7 @@ export default {
default: false, default: false,
}, },
}, },
emits: ["hangup"],
data() { data() {
return { return {
isMinimized: false, isMinimized: false,

View File

@@ -31,6 +31,17 @@
>{{ unreadVoicemailsCount }}</span >{{ unreadVoicemailsCount }}</span
> >
</button> </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 <button
:class="[ :class="[
activeTab === 'ringtone' activeTab === 'ringtone'
@@ -215,16 +226,16 @@
</div> </div>
<!-- actions --> <!-- 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 --> <!-- answer call -->
<button <button
v-if="activeCall.is_incoming && activeCall.status === 4" v-if="activeCall.is_incoming && activeCall.status === 4"
:title="$t('call.answer_call')" :title="$t('call.answer_call')"
type="button" 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" @click="answerCall"
> >
<MaterialDesignIcon icon-name="phone" class="size-5" /> <MaterialDesignIcon icon-name="phone" class="size-4" />
<span>{{ $t("call.accept") }}</span> <span>{{ $t("call.accept") }}</span>
</button> </button>
@@ -233,10 +244,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4" v-if="activeCall.is_incoming && activeCall.status === 4"
:title="$t('call.send_to_voicemail')" :title="$t('call.send_to_voicemail')"
type="button" 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" @click="sendToVoicemail"
> >
<MaterialDesignIcon icon-name="voicemail" class="size-5" /> <MaterialDesignIcon icon-name="voicemail" class="size-4" />
<span>{{ $t("call.send_to_voicemail") }}</span> <span>{{ $t("call.send_to_voicemail") }}</span>
</button> </button>
@@ -248,10 +259,10 @@
: $t('call.hangup_call') : $t('call.hangup_call')
" "
type="button" 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" @click="hangupCall"
> >
<MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" /> <MaterialDesignIcon icon-name="phone-hangup" class="size-4 rotate-[135deg]" />
<span>{{ <span>{{
activeCall.is_incoming && activeCall.status === 4 activeCall.is_incoming && activeCall.status === 4
? $t("call.decline") ? $t("call.decline")
@@ -403,6 +414,21 @@
<!-- Voicemail Tab --> <!-- Voicemail Tab -->
<div v-if="activeTab === 'voicemail'" class="flex-1 flex flex-col"> <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 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"> <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" /> <MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
@@ -530,6 +556,114 @@
</div> </div>
</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 --> <!-- Ringtone Tab -->
<div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6"> <div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6">
<div <div
@@ -933,6 +1067,16 @@ export default {
editingRingtoneId: null, editingRingtoneId: null,
editingRingtoneName: "", editingRingtoneName: "",
elapsedTimeInterval: null, elapsedTimeInterval: null,
voicemailSearch: "",
contactsSearch: "",
contacts: [],
isContactModalOpen: false,
editingContact: null,
contactForm: {
name: "",
remote_identity_hash: "",
},
searchDebounceTimeout: null,
}; };
}, },
computed: { computed: {
@@ -956,6 +1100,7 @@ export default {
this.getStatus(); this.getStatus();
this.getHistory(); this.getHistory();
this.getVoicemails(); this.getVoicemails();
this.getContacts();
this.getVoicemailStatus(); this.getVoicemailStatus();
this.getRingtones(); this.getRingtones();
this.getRingtoneStatus(); this.getRingtoneStatus();
@@ -971,6 +1116,7 @@ export default {
this.historyInterval = setInterval(() => { this.historyInterval = setInterval(() => {
this.getHistory(); this.getHistory();
this.getVoicemails(); this.getVoicemails();
this.getContacts();
}, 10000); }, 10000);
// update elapsed time every second // update elapsed time every second
@@ -1204,13 +1350,78 @@ export default {
}, },
async getVoicemails() { async getVoicemails() {
try { 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.voicemails = response.data.voicemails;
this.unreadVoicemailsCount = response.data.unread_count; this.unreadVoicemailsCount = response.data.unread_count;
} catch (e) { } catch (e) {
console.log(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() { async generateGreeting() {
this.isGeneratingGreeting = true; this.isGeneratingGreeting = true;
try { try {
@@ -1259,7 +1470,9 @@ export default {
}, },
async playVoicemail(voicemail) { async playVoicemail(voicemail) {
if (this.playingVoicemailId === voicemail.id) { if (this.playingVoicemailId === voicemail.id) {
this.audioPlayer.pause(); if (this.audioPlayer) {
this.audioPlayer.pause();
}
this.playingVoicemailId = null; this.playingVoicemailId = null;
return; return;
} }
@@ -1270,11 +1483,25 @@ export default {
this.playingVoicemailId = voicemail.id; this.playingVoicemailId = voicemail.id;
this.audioPlayer = new Audio(`/api/v1/telephone/voicemails/${voicemail.id}/audio`); 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.audioPlayer.onended = () => {
this.playingVoicemailId = null; this.playingVoicemailId = null;
}; };
try {
await this.audioPlayer.play();
} catch (e) {
console.error("Audio play failed:", e);
this.playingVoicemailId = null;
}
// Mark as read // Mark as read
if (!voicemail.is_read) { if (!voicemail.is_read) {
try { try {

View File

@@ -108,6 +108,11 @@
<MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4" /> <MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4" />
</IconButton> </IconButton>
<!-- share contact button -->
<IconButton title="Share Contact" @click="openShareContactModal">
<MaterialDesignIcon icon-name="notebook-outline" class="w-4 h-4" />
</IconButton>
<!-- close button --> <!-- close button -->
<IconButton title="Close" @click="close"> <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"> <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>
</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 --> <!-- chat items -->
<div <div
id="messages" id="messages"
@@ -885,6 +948,10 @@ export default {
autoScrollOnNewMessage: true, autoScrollOnNewMessage: true,
composeAddress: "", composeAddress: "",
isShareContactModalOpen: false,
contacts: [],
contactsSearch: "",
isRecordingAudioAttachment: false, isRecordingAudioAttachment: false,
audioAttachmentMicrophoneRecorder: null, audioAttachmentMicrophoneRecorder: null,
audioAttachmentMicrophoneRecorderCodec: null, audioAttachmentMicrophoneRecorderCodec: null,
@@ -912,6 +979,13 @@ export default {
}; };
}, },
computed: { 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() { isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 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 // 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() { async sendMessage() {
// do nothing if can't send message // do nothing if can't send message
if (!this.canSendMessage) { if (!this.canSendMessage) {

View File

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