feat(call): improve call handling by adding remote destination hash tracking, improving initiation status checks, and refining ringtone management in the frontend

This commit is contained in:
2026-01-04 17:16:23 -06:00
parent 5ef41b84d5
commit d836e7a2e8
13 changed files with 228 additions and 193 deletions

View File

@@ -1650,6 +1650,13 @@ class ReticulumMeshChat:
ctx = context or self.current_context
if not ctx:
return
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
print(
"on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call."
)
return
caller_hash = caller_identity.hash.hex()
# Check if caller is blocked
@@ -3872,6 +3879,9 @@ class ReticulumMeshChat:
if telephone_active_call is not None:
# remote_identity is already fetched and checked for None above
remote_hash = remote_identity.hash.hex()
remote_destination_hash = RNS.Destination.hash(
remote_identity, "lxmf", "delivery"
).hex()
remote_name = None
if self.telephone_manager.get_name_for_identity_hash:
remote_name = self.telephone_manager.get_name_for_identity_hash(
@@ -3896,6 +3906,7 @@ class ReticulumMeshChat:
active_call = {
"hash": telephone_active_call.hash.hex(),
"remote_identity_hash": remote_hash,
"remote_destination_hash": remote_destination_hash,
"remote_identity_name": remote_name,
"remote_icon": dict(remote_icon) if remote_icon else None,
"custom_image": custom_image,
@@ -4068,6 +4079,7 @@ class ReticulumMeshChat:
remote_identity_hash,
)
if lxmf_hash:
d["remote_destination_hash"] = lxmf_hash
icon = self.database.misc.get_user_icon(lxmf_hash)
if icon:
d["remote_icon"] = dict(icon)
@@ -4238,6 +4250,7 @@ class ReticulumMeshChat:
remote_identity_hash,
)
if lxmf_hash:
d["remote_destination_hash"] = lxmf_hash
icon = self.database.misc.get_user_icon(lxmf_hash)
if icon:
d["remote_icon"] = dict(icon)
@@ -4296,7 +4309,9 @@ class ReticulumMeshChat:
try:
voicemail_id = int(voicemail_id)
except (ValueError, TypeError):
return web.json_response({"message": "Invalid voicemail ID"}, status=400)
return web.json_response(
{"message": "Invalid voicemail ID"}, status=400
)
if not self.voicemail_manager:
return web.json_response(
@@ -9536,7 +9551,15 @@ class ReticulumMeshChat:
"Received an announce from "
+ RNS.prettyhexrep(destination_hash)
+ " for [lxst.telephony]"
+ (f" ({display_name})" if (display_name := parse_lxmf_display_name(base64.b64encode(app_data).decode() if app_data else None, None)) else "")
+ (
f" ({display_name})"
if (
display_name := parse_lxmf_display_name(
base64.b64encode(app_data).decode() if app_data else None, None
)
)
else ""
)
)
# track announce timestamp

View File

@@ -110,7 +110,7 @@ class TelephoneManager:
self.telephone.hangup()
except Exception as e:
RNS.log(f"TelephoneManager: Error during hangup: {e}", RNS.LOG_ERROR)
# Always clear initiation status on hangup to prevent "Dialing..." hang
self._update_initiation_status(None, None)
@@ -140,7 +140,7 @@ class TelephoneManager:
# Update start time to when it was actually established for duration calculation
self.call_start_time = time.time()
self.call_was_established = True
# Clear initiation status as soon as call is established
self._update_initiation_status(None, None)
@@ -296,21 +296,43 @@ class TelephoneManager:
self._update_initiation_status("Dialing...")
self.call_start_time = time.time()
self.call_is_incoming = False
# Use a thread for the blocking LXST call, but monitor status for early exit
# if established elsewhere or timed out/hung up
call_task = asyncio.create_task(asyncio.to_thread(self.telephone.call, destination_identity))
call_task = asyncio.create_task(
asyncio.to_thread(self.telephone.call, destination_identity)
)
start_wait = time.time()
# LXST telephone.call usually returns on establishment or timeout.
# We wait for it, but if status becomes established or ended, we can stop waiting.
while not call_task.done():
if self.telephone.call_status in [6, 0, 1]: # Established, Busy, Rejected
if self.telephone.call_status in [
6,
0,
1,
]: # Established, Busy, Rejected
break
if self.telephone.call_status == 3 and (time.time() - start_wait > 1.0): # Available (ended/timeout)
if self.telephone.call_status == 3 and (
time.time() - start_wait > 1.0
): # Available (ended/timeout)
break
await asyncio.sleep(0.5)
# If the task finished but we're still ringing or connecting,
# wait a bit more for establishment or definitive failure
if self.telephone.call_status in [4, 5]: # Ringing, Connecting
wait_until = time.time() + timeout_seconds
while time.time() < wait_until:
if self.telephone.call_status in [
6,
0,
1,
3,
]: # Established, Busy, Rejected, Ended
break
await asyncio.sleep(0.5)
return self.telephone.active_call
except Exception as e:

View File

@@ -550,14 +550,14 @@ class VoicemailManager:
"1",
temp_path,
]
result = subprocess.run(
cmd, capture_output=True, text=True, check=False
) # noqa: S603
result = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603
if result.returncode == 0 and os.path.exists(temp_path):
os.remove(filepath)
os.rename(temp_path, filepath)
RNS.log(f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG)
RNS.log(
f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG
)
else:
RNS.log(
f"Voicemail: ffmpeg failed to fix {filepath}: {result.stderr}",
@@ -581,7 +581,7 @@ class VoicemailManager:
"-f",
"lavfi",
"-i",
f"anullsrc=r=48000:cl=mono",
"anullsrc=r=48000:cl=mono",
"-t",
str(max(1, seconds)),
"-c:a",
@@ -598,7 +598,10 @@ class VoicemailManager:
RNS.LOG_ERROR,
)
except Exception as e:
RNS.log(f"Voicemail: Error creating silence file for {filepath}: {e}", RNS.LOG_ERROR)
RNS.log(
f"Voicemail: Error creating silence file for {filepath}: {e}",
RNS.LOG_ERROR,
)
return False
def start_greeting_recording(self):

View File

@@ -724,6 +724,10 @@ export default {
if (this.config?.do_not_disturb_enabled) {
break;
}
// If we are the caller (outgoing initiation), skip playing the incoming ringtone
if (this.initiationStatus) {
break;
}
NotificationUtils.showIncomingCallNotification();
this.updateTelephoneStatus();
this.playRingtone();
@@ -1059,13 +1063,32 @@ export default {
this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name;
// Update call ended state if needed
const justEnded = oldCall != null && this.activeCall == null;
if (justEnded) {
this.lastCall = oldCall;
this.toneGenerator.playBusyTone();
// Trigger history refresh
GlobalEmitter.emit("telephone-history-updated");
if (!this.wasDeclined) {
this.isCallEnded = true;
}
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => {
this.isCallEnded = false;
this.wasDeclined = false;
this.lastCall = null;
}, 5000);
}
// Handle outgoing ringback tone
if (this.initiationStatus === "Ringing...") {
this.toneGenerator.playRingback();
} else if (!this.initiationStatus && !this.activeCall) {
// Only stop if we're not ringing or in a call
// This might be too aggressive if called during a transition,
// but toneGenerator.stop() is safe to call multiple times.
} else if (!this.initiationStatus && !this.activeCall && !this.isCallEnded) {
// Only stop if we're not ringing, in a call, or just finished a call (busy tone playing)
this.toneGenerator.stop();
}
@@ -1092,8 +1115,8 @@ export default {
this.isCallWindowOpen = false;
}
// Handle ringtone
if (this.activeCall?.status === 4) {
// Handle ringtone (only for incoming ringing)
if (this.activeCall?.status === 4 && this.activeCall?.is_incoming) {
// Call is ringing
if (!this.ringtonePlayer && this.config?.custom_ringtone_enabled && !this.isFetchingRingtone) {
this.isFetchingRingtone = true;
@@ -1138,25 +1161,8 @@ export default {
}
// If call just ended, show ended state for a few seconds
if (oldCall != null && this.activeCall == null) {
this.lastCall = oldCall;
this.toneGenerator.playBusyTone();
// Trigger history refresh
GlobalEmitter.emit("telephone-history-updated");
if (this.wasDeclined) {
// Already set by hangupCall
} else {
this.isCallEnded = true;
}
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => {
this.isCallEnded = false;
this.wasDeclined = false;
this.lastCall = null;
}, 5000);
if (justEnded) {
// Handled above
} else if (this.activeCall != null) {
// if a new call starts, clear ended state
this.isCallEnded = false;

View File

@@ -191,7 +191,9 @@
{{ $t("call.recording_voicemail") }}
</span>
<span
v-else-if="activeCall && activeCall.is_incoming && activeCall.status === 4"
v-else-if="
activeCall && activeCall.is_incoming && activeCall.status === 4
"
class="text-blue-600 dark:text-blue-400 font-bold text-sm animate-bounce"
>{{ $t("call.incoming_call") }}</span
>
@@ -200,17 +202,25 @@
class="text-gray-700 dark:text-zinc-300 font-bold text-sm flex items-center gap-2"
>
<span v-if="activeCall && activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall && activeCall.status === 1" class="text-red-500"
<span
v-else-if="activeCall && activeCall.status === 1"
class="text-red-500"
>Rejected</span
>
<span v-else-if="activeCall && activeCall.status === 2" class="animate-pulse"
<span
v-else-if="activeCall && activeCall.status === 2"
class="animate-pulse"
>Calling...</span
>
<span v-else-if="activeCall && activeCall.status === 3">Available</span>
<span v-else-if="activeCall && activeCall.status === 4" class="animate-pulse"
<span
v-else-if="activeCall && activeCall.status === 4"
class="animate-pulse"
>Ringing...</span
>
<span v-else-if="activeCall && activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall && activeCall.status === 5"
>Connecting...</span
>
<span
v-else-if="activeCall && activeCall.status === 6"
class="text-green-500 flex items-center gap-2"
@@ -651,10 +661,10 @@
</div>
<div
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 truncate mt-0.5 cursor-pointer hover:text-blue-500 transition-colors"
:title="entry.remote_identity_hash"
@click.stop="copyHash(entry.remote_identity_hash)"
:title="entry.remote_destination_hash || entry.remote_identity_hash"
@click.stop="copyHash(entry.remote_destination_hash || entry.remote_identity_hash)"
>
{{ formatDestinationHash(entry.remote_identity_hash) }}
{{ formatDestinationHash(entry.remote_destination_hash || entry.remote_identity_hash) }}
</div>
</div>
@@ -1239,7 +1249,7 @@
type="button"
class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors"
@click="
destinationHash = voicemail.remote_identity_hash;
destinationHash = voicemail.remote_destination_hash || voicemail.remote_identity_hash;
activeTab = 'phone';
call(destinationHash);
"
@@ -2050,7 +2060,7 @@ export default {
) {
suggestions.push({
name: c.name,
hash: c.remote_identity_hash,
hash: c.remote_destination_hash || c.remote_identity_hash,
type: "contact",
icon: "account",
});
@@ -2069,7 +2079,7 @@ export default {
) {
suggestions.push({
name: h.remote_identity_name || h.remote_identity_hash.substring(0, 8),
hash: h.remote_identity_hash,
hash: h.remote_destination_hash || h.remote_identity_hash,
type: "history",
icon: "history",
});
@@ -2199,6 +2209,7 @@ export default {
const response = await window.axios.get("/api/v1/telephone/status");
const oldCall = this.activeCall;
const newCall = response.data.active_call;
const callStatus = response.data.call_status;
// Sync local mute state from backend
if (newCall) {
@@ -2213,6 +2224,14 @@ export default {
this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name;
// If no active call and status is idle/busy/rejected/available, clear stale initiation UI
const isIdleState = !this.activeCall && ![2, 4, 5].includes(callStatus);
if (isIdleState && this.initiationStatus) {
this.initiationStatus = null;
this.initiationTargetHash = null;
this.initiationTargetName = null;
}
if (this.activeCall?.is_voicemail) {
this.wasVoicemail = true;
}
@@ -2260,7 +2279,7 @@ export default {
this.editingContact = null;
this.contactForm = {
name: entry.remote_identity_name || "",
remote_identity_hash: entry.remote_identity_hash,
remote_identity_hash: entry.remote_destination_hash || entry.remote_identity_hash,
preferred_ringtone_id: null,
};
this.isContactModalOpen = true;
@@ -2863,6 +2882,12 @@ export default {
}
let hashToCall = identityHash.trim();
// Accept lxmf:// URIs or pasted text; extract first 64-char hex
const hexMatch = hashToCall.match(/[0-9a-fA-F]{64}/);
if (hexMatch) {
hashToCall = hexMatch[0];
}
hashToCall = hashToCall.toLowerCase();
// Try to resolve name from contacts
const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase());

View File

@@ -1082,8 +1082,12 @@
class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="isRawMessageModalOpen = false"
>
<div class="w-full max-w-2xl bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between shrink-0">
<div
class="w-full max-w-2xl bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
>
<div
class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between shrink-0"
>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Raw LXMF Message</h3>
<button
type="button"
@@ -1094,7 +1098,9 @@
</button>
</div>
<div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40">
<pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{ JSON.stringify(rawMessageData, null, 2) }}</pre>
<pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{
JSON.stringify(rawMessageData, null, 2)
}}</pre>
</div>
<div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
<button
@@ -1607,10 +1613,7 @@ export default {
// if content is only the paper message, or it already contains the detected text,
// we'll hide the raw content div to avoid double rendering.
const trimmedContent = content.trim();
if (
trimmedContent === items.paperMessage ||
trimmedContent.includes("Paper Message detected")
) {
if (trimmedContent === items.paperMessage || trimmedContent.includes("Paper Message detected")) {
items.isOnlyPaperMessage = true;
}
}
@@ -2040,7 +2043,7 @@ export default {
...chatItem.lxmf_message,
raw_uri: response.data.uri,
};
} catch (e) {
} catch {
// if URI is not available (message no longer in router), we show what we have
this.rawMessageData = { ...chatItem.lxmf_message };
}

View File

@@ -4,6 +4,7 @@ export default class ToneGenerator {
this.oscillator = null;
this.gainNode = null;
this.timeoutId = null;
this.currentTone = null; // 'ringback', 'busy', or null
}
_initAudioContext() {
@@ -13,8 +14,10 @@ export default class ToneGenerator {
}
playRingback() {
if (this.currentTone === "ringback") return;
this._initAudioContext();
this.stop();
this.currentTone = "ringback";
const play = () => {
const osc1 = this.audioCtx.createOscillator();
@@ -57,8 +60,10 @@ export default class ToneGenerator {
}
playBusyTone() {
if (this.currentTone === "busy") return;
this._initAudioContext();
this.stop();
this.currentTone = "busy";
const play = () => {
const osc = this.audioCtx.createOscillator();
@@ -89,12 +94,13 @@ export default class ToneGenerator {
};
play();
// Auto-stop busy tone after 4 seconds (4 cycles)
setTimeout(() => this.stop(), 4000);
}
stop() {
this.currentTone = null;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
@@ -102,21 +108,40 @@ export default class ToneGenerator {
if (this.oscillator) {
if (Array.isArray(this.oscillator)) {
this.oscillator.forEach(osc => {
try { osc.stop(); } catch (e) {}
try { osc.disconnect(); } catch (e) {}
this.oscillator.forEach((osc) => {
try {
osc.stop();
} catch {
// Ignore errors if oscillator is already stopped or disconnected
}
try {
osc.disconnect();
} catch {
// Ignore errors if oscillator is already disconnected
}
});
} else {
try { this.oscillator.stop(); } catch (e) {}
try { this.oscillator.disconnect(); } catch (e) {}
try {
this.oscillator.stop();
} catch {
// Ignore errors if oscillator is already stopped or disconnected
}
try {
this.oscillator.disconnect();
} catch {
// Ignore errors if oscillator is already disconnected
}
}
this.oscillator = null;
}
if (this.gainNode) {
try { this.gainNode.disconnect(); } catch (e) {}
try {
this.gainNode.disconnect();
} catch {
// Ignore errors if gain node is already disconnected
}
this.gainNode = null;
}
}
}