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.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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 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):
|
||||||
|
|||||||
@@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user