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:
@@ -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
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from .announces import AnnounceDAO
|
||||
from .config import ConfigDAO
|
||||
from .contacts import ContactsDAO
|
||||
from .legacy_migrator import LegacyMigrator
|
||||
from .messages import MessageDAO
|
||||
from .misc import MiscDAO
|
||||
from .provider import DatabaseProvider
|
||||
from .ringtones import RingtoneDAO
|
||||
from .schema import DatabaseSchema
|
||||
from .telemetry import TelemetryDAO
|
||||
from .telephone import TelephoneDAO
|
||||
from .voicemails import VoicemailDAO
|
||||
from .ringtones import RingtoneDAO
|
||||
|
||||
|
||||
class Database:
|
||||
@@ -23,6 +24,7 @@ class Database:
|
||||
self.telemetry = TelemetryDAO(self.provider)
|
||||
self.voicemails = VoicemailDAO(self.provider)
|
||||
self.ringtones = RingtoneDAO(self.provider)
|
||||
self.contacts = ContactsDAO(self.provider)
|
||||
|
||||
def initialize(self):
|
||||
self.schema.initialize()
|
||||
|
||||
@@ -18,13 +18,13 @@ class ConfigDAO:
|
||||
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))
|
||||
else:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
|
||||
# handle booleans specifically to ensure they are stored as "true"/"false"
|
||||
if isinstance(value, bool):
|
||||
value_str = "true" if value else "false"
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
|
||||
self.provider.execute(
|
||||
"""
|
||||
INSERT INTO config (key, value, created_at, updated_at)
|
||||
|
||||
60
meshchatx/src/backend/database/contacts.py
Normal file
60
meshchatx/src/backend/database/contacts.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class ContactsDAO:
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
|
||||
def add_contact(self, name, remote_identity_hash):
|
||||
self.provider.execute(
|
||||
"""
|
||||
INSERT INTO contacts (name, remote_identity_hash)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(remote_identity_hash) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(name, remote_identity_hash),
|
||||
)
|
||||
|
||||
def get_contacts(self, search=None, limit=100, offset=0):
|
||||
if search:
|
||||
return self.provider.fetchall(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE name LIKE ? OR remote_identity_hash LIKE ?
|
||||
ORDER BY name ASC LIMIT ? OFFSET ?
|
||||
""",
|
||||
(f"%{search}%", f"%{search}%", limit, offset),
|
||||
)
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
|
||||
def get_contact(self, contact_id):
|
||||
return self.provider.fetchone(
|
||||
"SELECT * FROM contacts WHERE id = ?",
|
||||
(contact_id,),
|
||||
)
|
||||
|
||||
def update_contact(self, contact_id, name=None, remote_identity_hash=None):
|
||||
if name and remote_identity_hash:
|
||||
self.provider.execute(
|
||||
"UPDATE contacts SET name = ?, remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(name, remote_identity_hash, contact_id),
|
||||
)
|
||||
elif name:
|
||||
self.provider.execute(
|
||||
"UPDATE contacts SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(name, contact_id),
|
||||
)
|
||||
elif remote_identity_hash:
|
||||
self.provider.execute(
|
||||
"UPDATE contacts SET remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(remote_identity_hash, contact_id),
|
||||
)
|
||||
|
||||
def delete_contact(self, contact_id):
|
||||
self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class RingtoneDAO:
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
@@ -18,14 +20,14 @@ class RingtoneDAO:
|
||||
now = datetime.now(UTC)
|
||||
if display_name is None:
|
||||
display_name = filename
|
||||
|
||||
|
||||
# check if this is the first ringtone, if so make it primary
|
||||
count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")["count"]
|
||||
is_primary = 1 if count == 0 else 0
|
||||
|
||||
|
||||
cursor = self.provider.execute(
|
||||
"INSERT INTO ringtones (filename, display_name, storage_filename, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(filename, display_name, storage_filename, is_primary, now, now)
|
||||
(filename, display_name, storage_filename, is_primary, now, now),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
@@ -34,21 +36,21 @@ class RingtoneDAO:
|
||||
if is_primary == 1:
|
||||
# reset others
|
||||
self.provider.execute("UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,))
|
||||
|
||||
|
||||
if display_name is not None and is_primary is not None:
|
||||
self.provider.execute(
|
||||
"UPDATE ringtones SET display_name = ?, is_primary = ?, updated_at = ? WHERE id = ?",
|
||||
(display_name, is_primary, now, ringtone_id)
|
||||
(display_name, is_primary, now, ringtone_id),
|
||||
)
|
||||
elif display_name is not None:
|
||||
self.provider.execute(
|
||||
"UPDATE ringtones SET display_name = ?, updated_at = ? WHERE id = ?",
|
||||
(display_name, now, ringtone_id)
|
||||
(display_name, now, ringtone_id),
|
||||
)
|
||||
elif is_primary is not None:
|
||||
self.provider.execute(
|
||||
"UPDATE ringtones SET is_primary = ?, updated_at = ? WHERE id = ?",
|
||||
(is_primary, now, ringtone_id)
|
||||
(is_primary, now, ringtone_id),
|
||||
)
|
||||
|
||||
def delete(self, ringtone_id):
|
||||
|
||||
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class DatabaseSchema:
|
||||
LATEST_VERSION = 17
|
||||
LATEST_VERSION = 18
|
||||
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
@@ -238,6 +238,15 @@ class DatabaseSchema:
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"contacts": """
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
remote_identity_hash TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
}
|
||||
|
||||
for table_name, create_sql in tables.items():
|
||||
@@ -525,6 +534,23 @@ class DatabaseSchema:
|
||||
)
|
||||
""")
|
||||
|
||||
if current_version < 18:
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
remote_identity_hash TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self.provider.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)",
|
||||
)
|
||||
self.provider.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_contacts_remote_identity_hash ON contacts(remote_identity_hash)",
|
||||
)
|
||||
|
||||
# Update version in config
|
||||
self.provider.execute(
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ class TelemetryDAO:
|
||||
self.provider = provider
|
||||
|
||||
def upsert_telemetry(
|
||||
self, destination_hash, timestamp, data, received_from=None, physical_link=None
|
||||
self, destination_hash, timestamp, data, received_from=None, physical_link=None,
|
||||
):
|
||||
now = datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
@@ -37,7 +37,16 @@ class VoicemailDAO:
|
||||
),
|
||||
)
|
||||
|
||||
def get_voicemails(self, limit=50, offset=0):
|
||||
def get_voicemails(self, search=None, limit=50, offset=0):
|
||||
if search:
|
||||
return self.provider.fetchall(
|
||||
"""
|
||||
SELECT * FROM voicemails
|
||||
WHERE remote_identity_name LIKE ? OR remote_identity_hash LIKE ?
|
||||
ORDER BY timestamp DESC LIMIT ? OFFSET ?
|
||||
""",
|
||||
(f"%{search}%", f"%{search}%", limit, offset),
|
||||
)
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import RNS
|
||||
|
||||
|
||||
class RingtoneManager:
|
||||
def __init__(self, config, storage_dir):
|
||||
self.config = config
|
||||
self.storage_dir = os.path.join(storage_dir, "ringtones")
|
||||
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(self.storage_dir, exist_ok=True)
|
||||
|
||||
|
||||
# Paths to executables
|
||||
self.ffmpeg_path = self._find_ffmpeg()
|
||||
self.has_ffmpeg = self.ffmpeg_path is not None
|
||||
|
||||
|
||||
if self.has_ffmpeg:
|
||||
RNS.log(f"Ringtone: Found ffmpeg at {self.ffmpeg_path}", RNS.LOG_DEBUG)
|
||||
else:
|
||||
|
||||
@@ -325,6 +325,10 @@ class VoicemailManager:
|
||||
|
||||
try:
|
||||
self.recording_sink = OpusFileSink(filepath)
|
||||
# Ensure samplerate is set to avoid TypeError in LXST Opus codec
|
||||
# which expects sink to have a valid samplerate attribute
|
||||
self.recording_sink.samplerate = 48000
|
||||
|
||||
# Connect the caller's audio source to our sink
|
||||
# active_call.audio_source is a LinkSource that feeds into receive_mixer
|
||||
# We want to record what we receive.
|
||||
|
||||
@@ -129,12 +129,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div v-if="!isEnded && !wasDeclined" class="flex flex-wrap justify-center gap-3">
|
||||
<div v-if="!isEnded && !wasDeclined" class="flex flex-wrap justify-center gap-2 px-2">
|
||||
<!-- Mute Mic -->
|
||||
<button
|
||||
type="button"
|
||||
:title="isMicMuted ? $t('call.unmute_mic') : $t('call.mute_mic')"
|
||||
class="p-3 rounded-full transition-all duration-200"
|
||||
class="p-2.5 rounded-full transition-all duration-200"
|
||||
:class="
|
||||
isMicMuted
|
||||
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
|
||||
@@ -142,14 +142,14 @@
|
||||
"
|
||||
@click="toggleMicrophone"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-6" />
|
||||
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-5" />
|
||||
</button>
|
||||
|
||||
<!-- Mute Speaker -->
|
||||
<button
|
||||
type="button"
|
||||
:title="isSpeakerMuted ? $t('call.unmute_speaker') : $t('call.mute_speaker')"
|
||||
class="p-3 rounded-full transition-all duration-200"
|
||||
class="p-2.5 rounded-full transition-all duration-200"
|
||||
:class="
|
||||
isSpeakerMuted
|
||||
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
|
||||
@@ -157,7 +157,7 @@
|
||||
"
|
||||
@click="toggleSpeaker"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-6" />
|
||||
<MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-5" />
|
||||
</button>
|
||||
|
||||
<!-- Hangup -->
|
||||
@@ -168,10 +168,10 @@
|
||||
? $t('call.decline_call')
|
||||
: $t('call.hangup_call')
|
||||
"
|
||||
class="p-3 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
|
||||
class="p-2.5 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
|
||||
@click="hangupCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
|
||||
<MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
|
||||
</button>
|
||||
|
||||
<!-- Send to Voicemail (if incoming) -->
|
||||
@@ -179,10 +179,10 @@
|
||||
v-if="activeCall.is_incoming && activeCall.status === 4"
|
||||
type="button"
|
||||
:title="$t('call.send_to_voicemail')"
|
||||
class="p-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
|
||||
class="p-2.5 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
|
||||
@click="sendToVoicemail"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="voicemail" class="size-6" />
|
||||
<MaterialDesignIcon icon-name="voicemail" class="size-5" />
|
||||
</button>
|
||||
|
||||
<!-- Answer (if incoming) -->
|
||||
@@ -190,10 +190,10 @@
|
||||
v-if="activeCall.is_incoming && activeCall.status === 4"
|
||||
type="button"
|
||||
:title="$t('call.answer_call')"
|
||||
class="p-3 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
|
||||
class="p-2.5 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
|
||||
@click="answerCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone" class="size-6" />
|
||||
<MaterialDesignIcon icon-name="phone" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,6 +271,7 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["hangup"],
|
||||
data() {
|
||||
return {
|
||||
isMinimized: false,
|
||||
|
||||
@@ -31,6 +31,17 @@
|
||||
>{{ unreadVoicemailsCount }}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
activeTab === 'contacts'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
|
||||
]"
|
||||
class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
|
||||
@click="activeTab = 'contacts'"
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
activeTab === 'ringtone'
|
||||
@@ -215,16 +226,16 @@
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div v-if="activeCall" class="flex flex-wrap justify-center gap-4 mt-6">
|
||||
<div v-if="activeCall" class="flex flex-wrap justify-center gap-4 mt-8 mb-4">
|
||||
<!-- answer call -->
|
||||
<button
|
||||
v-if="activeCall.is_incoming && activeCall.status === 4"
|
||||
:title="$t('call.answer_call')"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
|
||||
@click="answerCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone" class="size-5" />
|
||||
<MaterialDesignIcon icon-name="phone" class="size-4" />
|
||||
<span>{{ $t("call.accept") }}</span>
|
||||
</button>
|
||||
|
||||
@@ -233,10 +244,10 @@
|
||||
v-if="activeCall.is_incoming && activeCall.status === 4"
|
||||
:title="$t('call.send_to_voicemail')"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200"
|
||||
@click="sendToVoicemail"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="voicemail" class="size-5" />
|
||||
<MaterialDesignIcon icon-name="voicemail" class="size-4" />
|
||||
<span>{{ $t("call.send_to_voicemail") }}</span>
|
||||
</button>
|
||||
|
||||
@@ -248,10 +259,10 @@
|
||||
: $t('call.hangup_call')
|
||||
"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
|
||||
@click="hangupCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
|
||||
<MaterialDesignIcon icon-name="phone-hangup" class="size-4 rotate-[135deg]" />
|
||||
<span>{{
|
||||
activeCall.is_incoming && activeCall.status === 4
|
||||
? $t("call.decline")
|
||||
@@ -403,6 +414,21 @@
|
||||
|
||||
<!-- Voicemail Tab -->
|
||||
<div v-if="activeTab === 'voicemail'" class="flex-1 flex flex-col">
|
||||
<div class="mb-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="voicemailSearch"
|
||||
type="text"
|
||||
placeholder="Search voicemails..."
|
||||
class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
|
||||
@input="onVoicemailSearchInput"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="voicemails.length === 0" class="my-auto text-center">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
|
||||
<MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
|
||||
@@ -530,6 +556,114 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts Tab -->
|
||||
<div v-if="activeTab === 'contacts'" class="flex-1 flex flex-col">
|
||||
<div class="mb-4 flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="contactsSearch"
|
||||
type="text"
|
||||
placeholder="Search contacts..."
|
||||
class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
|
||||
@input="onContactsSearchInput"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-colors flex items-center gap-2"
|
||||
@click="openAddContactModal"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="plus" class="size-5" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="contacts.length === 0" class="my-auto text-center">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
|
||||
<MaterialDesignIcon icon-name="account-multiple" class="size-12 text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No Contacts</h3>
|
||||
<p class="text-gray-500 dark:text-zinc-400">Add contacts to quickly call them.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
|
||||
>
|
||||
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
|
||||
<li
|
||||
v-for="contact in contacts"
|
||||
:key="contact.id"
|
||||
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="shrink-0">
|
||||
<LxmfUserIcon
|
||||
v-if="contact.remote_icon"
|
||||
:icon-name="contact.remote_icon.icon_name"
|
||||
:icon-foreground-colour="contact.remote_icon.foreground_colour"
|
||||
:icon-background-colour="contact.remote_icon.background_colour"
|
||||
class="size-10"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account" class="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-bold text-gray-900 dark:text-white truncate">
|
||||
{{ contact.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
@click="openEditContactModal(contact)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="pencil" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 transition-colors"
|
||||
@click="deleteContact(contact.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span
|
||||
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate"
|
||||
:title="contact.remote_identity_hash"
|
||||
>
|
||||
{{ formatDestinationHash(contact.remote_identity_hash) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
|
||||
@click="
|
||||
destinationHash = contact.remote_identity_hash;
|
||||
activeTab = 'phone';
|
||||
call(destinationHash);
|
||||
"
|
||||
>
|
||||
Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ringtone Tab -->
|
||||
<div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6">
|
||||
<div
|
||||
@@ -933,6 +1067,16 @@ export default {
|
||||
editingRingtoneId: null,
|
||||
editingRingtoneName: "",
|
||||
elapsedTimeInterval: null,
|
||||
voicemailSearch: "",
|
||||
contactsSearch: "",
|
||||
contacts: [],
|
||||
isContactModalOpen: false,
|
||||
editingContact: null,
|
||||
contactForm: {
|
||||
name: "",
|
||||
remote_identity_hash: "",
|
||||
},
|
||||
searchDebounceTimeout: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -956,6 +1100,7 @@ export default {
|
||||
this.getStatus();
|
||||
this.getHistory();
|
||||
this.getVoicemails();
|
||||
this.getContacts();
|
||||
this.getVoicemailStatus();
|
||||
this.getRingtones();
|
||||
this.getRingtoneStatus();
|
||||
@@ -971,6 +1116,7 @@ export default {
|
||||
this.historyInterval = setInterval(() => {
|
||||
this.getHistory();
|
||||
this.getVoicemails();
|
||||
this.getContacts();
|
||||
}, 10000);
|
||||
|
||||
// update elapsed time every second
|
||||
@@ -1204,13 +1350,78 @@ export default {
|
||||
},
|
||||
async getVoicemails() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/voicemails");
|
||||
const response = await window.axios.get("/api/v1/telephone/voicemails", {
|
||||
params: { search: this.voicemailSearch },
|
||||
});
|
||||
this.voicemails = response.data.voicemails;
|
||||
this.unreadVoicemailsCount = response.data.unread_count;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
onVoicemailSearchInput() {
|
||||
if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
|
||||
this.searchDebounceTimeout = setTimeout(() => {
|
||||
this.getVoicemails();
|
||||
}, 300);
|
||||
},
|
||||
async getContacts() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/contacts", {
|
||||
params: { search: this.contactsSearch },
|
||||
});
|
||||
this.contacts = response.data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
onContactsSearchInput() {
|
||||
if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
|
||||
this.searchDebounceTimeout = setTimeout(() => {
|
||||
this.getContacts();
|
||||
}, 300);
|
||||
},
|
||||
openAddContactModal() {
|
||||
this.editingContact = null;
|
||||
this.contactForm = { name: "", remote_identity_hash: "" };
|
||||
const name = prompt("Enter contact name:");
|
||||
if (!name) return;
|
||||
const hash = prompt("Enter identity hash:");
|
||||
if (!hash) return;
|
||||
this.saveContact({ name, remote_identity_hash: hash });
|
||||
},
|
||||
openEditContactModal(contact) {
|
||||
this.editingContact = contact;
|
||||
const name = prompt("Edit contact name:", contact.name);
|
||||
if (!name) return;
|
||||
const hash = prompt("Edit identity hash:", contact.remote_identity_hash);
|
||||
if (!hash) return;
|
||||
this.saveContact({ id: contact.id, name, remote_identity_hash: hash });
|
||||
},
|
||||
async saveContact(contact) {
|
||||
try {
|
||||
if (contact.id) {
|
||||
await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, contact);
|
||||
ToastUtils.success("Contact updated");
|
||||
} else {
|
||||
await window.axios.post("/api/v1/telephone/contacts", contact);
|
||||
ToastUtils.success("Contact added");
|
||||
}
|
||||
this.getContacts();
|
||||
} catch (e) {
|
||||
ToastUtils.error(e.response?.data?.message || "Failed to save contact");
|
||||
}
|
||||
},
|
||||
async deleteContact(contactId) {
|
||||
if (!confirm("Are you sure you want to delete this contact?")) return;
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/telephone/contacts/${contactId}`);
|
||||
ToastUtils.success("Contact deleted");
|
||||
this.getContacts();
|
||||
} catch {
|
||||
ToastUtils.error("Failed to delete contact");
|
||||
}
|
||||
},
|
||||
async generateGreeting() {
|
||||
this.isGeneratingGreeting = true;
|
||||
try {
|
||||
@@ -1259,7 +1470,9 @@ export default {
|
||||
},
|
||||
async playVoicemail(voicemail) {
|
||||
if (this.playingVoicemailId === voicemail.id) {
|
||||
this.audioPlayer.pause();
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.pause();
|
||||
}
|
||||
this.playingVoicemailId = null;
|
||||
return;
|
||||
}
|
||||
@@ -1270,11 +1483,25 @@ export default {
|
||||
|
||||
this.playingVoicemailId = voicemail.id;
|
||||
this.audioPlayer = new Audio(`/api/v1/telephone/voicemails/${voicemail.id}/audio`);
|
||||
this.audioPlayer.play();
|
||||
|
||||
this.audioPlayer.addEventListener("error", (e) => {
|
||||
console.error("Audio player error:", e);
|
||||
ToastUtils.error(this.$t("call.failed_to_play_voicemail") || "Failed to load voicemail audio");
|
||||
this.playingVoicemailId = null;
|
||||
this.audioPlayer = null;
|
||||
});
|
||||
|
||||
this.audioPlayer.onended = () => {
|
||||
this.playingVoicemailId = null;
|
||||
};
|
||||
|
||||
try {
|
||||
await this.audioPlayer.play();
|
||||
} catch (e) {
|
||||
console.error("Audio play failed:", e);
|
||||
this.playingVoicemailId = null;
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
if (!voicemail.is_read) {
|
||||
try {
|
||||
|
||||
@@ -108,6 +108,11 @@
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4" />
|
||||
</IconButton>
|
||||
|
||||
<!-- share contact button -->
|
||||
<IconButton title="Share Contact" @click="openShareContactModal">
|
||||
<MaterialDesignIcon icon-name="notebook-outline" class="w-4 h-4" />
|
||||
</IconButton>
|
||||
|
||||
<!-- close button -->
|
||||
<IconButton title="Close" @click="close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
@@ -119,6 +124,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Contact Modal -->
|
||||
<div
|
||||
v-if="isShareContactModalOpen"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click.self="isShareContactModalOpen = false"
|
||||
>
|
||||
<div class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Share Contact</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
|
||||
@click="isShareContactModalOpen = false"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="contactsSearch"
|
||||
type="text"
|
||||
placeholder="Search contacts..."
|
||||
class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto space-y-2">
|
||||
<button
|
||||
v-for="contact in filteredContacts"
|
||||
:key="contact.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
|
||||
@click="shareContact(contact)"
|
||||
>
|
||||
<div
|
||||
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account" class="size-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-gray-900 dark:text-white truncate">
|
||||
{{ contact.name }}
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
|
||||
{{ contact.remote_identity_hash }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- chat items -->
|
||||
<div
|
||||
id="messages"
|
||||
@@ -885,6 +948,10 @@ export default {
|
||||
autoScrollOnNewMessage: true,
|
||||
composeAddress: "",
|
||||
|
||||
isShareContactModalOpen: false,
|
||||
contacts: [],
|
||||
contactsSearch: "",
|
||||
|
||||
isRecordingAudioAttachment: false,
|
||||
audioAttachmentMicrophoneRecorder: null,
|
||||
audioAttachmentMicrophoneRecorderCodec: null,
|
||||
@@ -912,6 +979,13 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredContacts() {
|
||||
if (!this.contactsSearch) return this.contacts;
|
||||
const s = this.contactsSearch.toLowerCase();
|
||||
return this.contacts.filter(
|
||||
(c) => c.name.toLowerCase().includes(s) || c.remote_identity_hash.toLowerCase().includes(s)
|
||||
);
|
||||
},
|
||||
isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
},
|
||||
@@ -1612,6 +1686,27 @@ export default {
|
||||
// do nothing if failed to delete message
|
||||
}
|
||||
},
|
||||
async openShareContactModal() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/contacts");
|
||||
this.contacts = response.data;
|
||||
|
||||
if (this.contacts.length === 0) {
|
||||
ToastUtils.info("No contacts found in telephone");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isShareContactModalOpen = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to load contacts");
|
||||
}
|
||||
},
|
||||
async shareContact(contact) {
|
||||
this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
|
||||
this.isShareContactModalOpen = false;
|
||||
await this.sendMessage();
|
||||
},
|
||||
async sendMessage() {
|
||||
// do nothing if can't send message
|
||||
if (!this.canSendMessage) {
|
||||
|
||||
@@ -3,4 +3,4 @@ Auto-generated helper so Python tooling and the Electron build
|
||||
share the same version string.
|
||||
"""
|
||||
|
||||
__version__ = '3.0.0'
|
||||
__version__ = '3.1.0'
|
||||
|
||||
Reference in New Issue
Block a user