diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 13bb054..6024141 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -419,14 +419,26 @@ + + + + @@ -463,13 +475,18 @@ import GlobalEmitter from "../js/GlobalEmitter"; import NotificationUtils from "../js/NotificationUtils"; import LxmfUserIcon from "./LxmfUserIcon.vue"; import Toast from "./Toast.vue"; +import ConfirmDialog from "./ConfirmDialog.vue"; import ToastUtils from "../js/ToastUtils"; import MaterialDesignIcon from "./MaterialDesignIcon.vue"; import NotificationBell from "./NotificationBell.vue"; import LanguageSelector from "./LanguageSelector.vue"; import CallOverlay from "./call/CallOverlay.vue"; import CommandPalette from "./CommandPalette.vue"; +import IntegrityWarningModal from "./IntegrityWarningModal.vue"; +import ChangelogModal from "./ChangelogModal.vue"; +import TutorialModal from "./TutorialModal.vue"; import KeyboardShortcuts from "../js/KeyboardShortcuts"; +import ElectronUtils from "../js/ElectronUtils"; import logoUrl from "../assets/images/logo.png"; export default { @@ -478,15 +495,20 @@ export default { LxmfUserIcon, SidebarLink, Toast, + ConfirmDialog, MaterialDesignIcon, NotificationBell, LanguageSelector, CallOverlay, CommandPalette, + IntegrityWarningModal, + ChangelogModal, + TutorialModal, }, data() { return { logoUrl, + ElectronUtils, reloadInterval: null, appInfoInterval: null, @@ -501,14 +523,22 @@ export default { displayName: "Anonymous Peer", config: null, appInfo: null, + hasCheckedForModals: false, activeCall: null, propagationNodeStatus: null, isCallEnded: false, wasDeclined: false, lastCall: null, + voicemailStatus: null, + isMicMuting: false, + isSpeakerMuting: false, endedTimeout: null, ringtonePlayer: null, + isFetchingRingtone: false, + initiationStatus: null, + initiationTargetHash: null, + isCallWindowOpen: false, }; }, computed: { @@ -535,6 +565,9 @@ export default { "complete", ].includes(this.propagationNodeStatus?.state); }, + activeCallTab() { + return GlobalState.activeCallTab; + }, }, watch: { $route() { @@ -591,13 +624,25 @@ export default { this.handleKeyboardShortcut(action); }); + GlobalEmitter.on("block-status-changed", () => { + this.getBlockedDestinations(); + }); + this.getAppInfo(); this.getConfig(); + this.getBlockedDestinations(); this.getKeyboardShortcuts(); this.updateRingtonePlayer(); this.updateTelephoneStatus(); this.updatePropagationNodeStatus(); + // listen for protocol links in electron + if (ElectronUtils.isElectron()) { + window.electron.onProtocolLink((url) => { + this.handleProtocolLink(url); + }); + } + // update info every few seconds this.reloadInterval = setInterval(() => { this.updateTelephoneStatus(); @@ -618,6 +663,7 @@ export default { switch (json.type) { case "config": { this.config = json.config; + GlobalState.config = json.config; this.displayName = json.config.display_name; if (this.config?.theme) { if (this.config.theme === "dark") { @@ -652,6 +698,11 @@ export default { ); break; } + case "telephone_initiation_status": { + this.initiationStatus = json.status; + this.initiationTargetHash = json.target_hash; + break; + } case "new_voicemail": { NotificationUtils.showNewVoicemailNotification( json.remote_identity_name || json.remote_identity_hash @@ -662,9 +713,24 @@ export default { case "telephone_call_established": case "telephone_call_ended": { this.stopRingtone(); + this.ringtonePlayer = null; this.updateTelephoneStatus(); break; } + case "lxmf.delivery": { + if (this.config?.do_not_disturb_enabled) { + break; + } + + // show notification for new messages if window is not focussed + if (!document.hasFocus()) { + NotificationUtils.showNewMessageNotification( + json.remote_identity_name, + json.lxmf_message?.content + ); + } + break; + } case "identity_switched": { ToastUtils.success(`Switched to identity: ${json.display_name}`); @@ -689,6 +755,17 @@ export default { try { const response = await window.axios.get(`/api/v1/app/info`); this.appInfo = response.data.app_info; + + // check if we should show tutorial or changelog (only on first load) + if (!this.hasCheckedForModals) { + this.hasCheckedForModals = true; + if (this.appInfo && !this.appInfo.tutorial_seen) { + this.$refs.tutorialModal.show(); + } else if (this.appInfo && this.appInfo.changelog_seen_version !== this.appInfo.version) { + // show changelog if version changed + this.$refs.changelogModal.show(); + } + } } catch (e) { // do nothing if failed to load app info console.log(e); @@ -698,6 +775,7 @@ export default { try { const response = await window.axios.get(`/api/v1/config`); this.config = response.data.config; + GlobalState.config = response.data.config; if (this.config?.theme) { if (this.config.theme === "dark") { document.documentElement.classList.add("dark"); @@ -710,6 +788,14 @@ export default { console.log(e); } }, + async getBlockedDestinations() { + try { + const response = await window.axios.get("/api/v1/blocked-destinations"); + GlobalState.blockedDestinations = response.data.blocked_destinations || []; + } catch (e) { + console.log("Failed to load blocked destinations:", e); + } + }, async getKeyboardShortcuts() { WebSocketConnection.send( JSON.stringify({ @@ -865,6 +951,9 @@ export default { if (status.has_custom_ringtone && status.id) { this.ringtonePlayer = new Audio(`/api/v1/telephone/ringtones/${status.id}/audio`); this.ringtonePlayer.loop = true; + if (status.volume !== undefined) { + this.ringtonePlayer.volume = status.volume; + } } } catch (e) { console.error("Failed to update ringtone player:", e); @@ -873,15 +962,21 @@ export default { }, playRingtone() { if (this.ringtonePlayer) { - this.ringtonePlayer.play().catch((e) => { - console.log("Failed to play custom ringtone:", e); - }); + if (this.ringtonePlayer.paused) { + this.ringtonePlayer.play().catch((e) => { + console.log("Failed to play custom ringtone:", e); + }); + } } }, stopRingtone() { if (this.ringtonePlayer) { - this.ringtonePlayer.pause(); - this.ringtonePlayer.currentTime = 0; + try { + this.ringtonePlayer.pause(); + this.ringtonePlayer.currentTime = 0; + } catch { + // ignore errors during pause + } } }, async updateTelephoneStatus() { @@ -889,13 +984,80 @@ export default { // fetch status const response = await axios.get("/api/v1/telephone/status"); const oldCall = this.activeCall; + const newCall = response.data.active_call; // update ui - this.activeCall = response.data.active_call; + this.activeCall = newCall; + this.voicemailStatus = response.data.voicemail; + this.initiationStatus = response.data.initiation_status; + this.initiationTargetHash = response.data.initiation_target_hash; - // Stop ringtone if not ringing anymore - if (this.activeCall?.status !== 4) { - this.stopRingtone(); + // Handle power management for calls + if (ElectronUtils.isElectron()) { + if (this.activeCall) { + window.electron.setPowerSaveBlocker(true); + } else if (!this.initiationStatus) { + window.electron.setPowerSaveBlocker(false); + } + } + + // Handle opening call in separate window if enabled + if ( + (this.activeCall || this.initiationStatus) && + this.config?.desktop_open_calls_in_separate_window && + ElectronUtils.isElectron() + ) { + if (!this.isCallWindowOpen && !this.$route.meta.isPopout) { + this.isCallWindowOpen = true; + window.open("/call.html", "_blank", "width=600,height=800"); + } + } else { + this.isCallWindowOpen = false; + } + + // Handle ringtone + if (this.activeCall?.status === 4) { + // Call is ringing + if (!this.ringtonePlayer && this.config?.custom_ringtone_enabled && !this.isFetchingRingtone) { + this.isFetchingRingtone = true; + try { + const caller_hash = this.activeCall.remote_identity_hash; + const ringResponse = await window.axios.get( + `/api/v1/telephone/ringtones/status?caller_hash=${caller_hash}` + ); + const status = ringResponse.data; + if (status.has_custom_ringtone && status.id) { + // Double check if we still need to play it (call might have ended during await) + if (this.activeCall?.status === 4) { + // Stop any existing player just in case + this.stopRingtone(); + + this.ringtonePlayer = new Audio(`/api/v1/telephone/ringtones/${status.id}/audio`); + this.ringtonePlayer.loop = true; + if (status.volume !== undefined) { + this.ringtonePlayer.volume = status.volume; + } + this.playRingtone(); + } + } + } finally { + this.isFetchingRingtone = false; + } + } else if (this.ringtonePlayer && this.activeCall?.status === 4) { + this.playRingtone(); + } + } else { + // Not ringing + if (this.ringtonePlayer) { + this.stopRingtone(); + this.ringtonePlayer = null; + } + } + + // Preserve local mute state if we're currently toggling + if (newCall && oldCall) { + newCall.is_mic_muted = oldCall.is_mic_muted; + newCall.is_speaker_muted = oldCall.is_speaker_muted; } // If call just ended, show ended state for a few seconds @@ -935,6 +1097,24 @@ export default { this.wasDeclined = true; } }, + onToggleMic(isMuted) { + this.isMicMuting = true; + if (this.activeCall) { + this.activeCall.is_mic_muted = isMuted; + } + setTimeout(() => { + this.isMicMuting = false; + }, 2000); + }, + onToggleSpeaker(isMuted) { + this.isSpeakerMuting = true; + if (this.activeCall) { + this.activeCall.is_speaker_muted = isMuted; + } + setTimeout(() => { + this.isSpeakerMuting = false; + }, 2000); + }, onAppNameClick() { // user may be on mobile, and is unable to scroll back to sidebar, so let them tap app name to do it this.$refs["middle"].scrollTo({ @@ -943,6 +1123,20 @@ export default { behavior: "smooth", }); }, + handleProtocolLink(url) { + try { + // lxmf:// or rns:// + const hash = url.replace("lxmf://", "").replace("rns://", "").split("/")[0].replace("/", ""); + if (hash && hash.length === 32) { + this.$router.push({ + name: "messages", + params: { destinationHash: hash }, + }); + } + } catch (e) { + console.error("Failed to handle protocol link:", e); + } + }, handleKeyboardShortcut(action) { switch (action) { case "nav_messages": @@ -984,7 +1178,25 @@ export default { }; - diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue index 9034c5d..df02c70 100644 --- a/meshchatx/src/frontend/components/CommandPalette.vue +++ b/meshchatx/src/frontend/components/CommandPalette.vue @@ -63,6 +63,7 @@ > + +
+
+ +
+
+
+
+ +
+
+

+ Confirm +

+

+ {{ pendingConfirm.message }} +

+
+
+ +
+ + +
+
+
+
+
+ + + + + diff --git a/meshchatx/src/frontend/components/IntegrityWarningModal.vue b/meshchatx/src/frontend/components/IntegrityWarningModal.vue new file mode 100644 index 0000000..e30df5d --- /dev/null +++ b/meshchatx/src/frontend/components/IntegrityWarningModal.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/meshchatx/src/frontend/components/LxmfUserIcon.vue b/meshchatx/src/frontend/components/LxmfUserIcon.vue index 9ac5f95..be96beb 100644 --- a/meshchatx/src/frontend/components/LxmfUserIcon.vue +++ b/meshchatx/src/frontend/components/LxmfUserIcon.vue @@ -1,6 +1,9 @@ + + diff --git a/meshchatx/src/frontend/components/call/RingtoneEditorModal.vue b/meshchatx/src/frontend/components/call/RingtoneEditorModal.vue new file mode 100644 index 0000000..0d2e428 --- /dev/null +++ b/meshchatx/src/frontend/components/call/RingtoneEditorModal.vue @@ -0,0 +1,526 @@ + + + + + diff --git a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue new file mode 100644 index 0000000..83abf86 --- /dev/null +++ b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue @@ -0,0 +1,110 @@ + + + diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue new file mode 100644 index 0000000..c03c690 --- /dev/null +++ b/meshchatx/src/frontend/components/docs/DocsPage.vue @@ -0,0 +1,623 @@ + + + + + diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue index 72c62e7..4f12d8c 100644 --- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue +++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue @@ -37,36 +37,23 @@
-
+
-
RNS Testnet Amsterdam
+
{{ communityIface.name }}
- amsterdam.connect.reticulum.network:4965 + {{ communityIface.target_host }}:{{ communityIface.target_port }} + Online + Offline
-
-
- -
-
- -
-
-
- RNS Testnet BetweenTheBorders -
-
- reticulum.betweentheborders.com:4242 + {{ communityIface.description }}
@@ -74,10 +61,10 @@ type="button" class="inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500" @click=" - newInterfaceName = 'RNS Testnet BetweenTheBorders'; - newInterfaceType = 'TCPClientInterface'; - newInterfaceTargetHost = 'reticulum.betweentheborders.com'; - newInterfaceTargetPort = '4242'; + newInterfaceName = communityIface.name; + newInterfaceType = communityIface.type; + newInterfaceTargetHost = communityIface.target_host; + newInterfaceTargetPort = communityIface.target_port; " > Use Interface @@ -1097,6 +1084,8 @@ export default { config: null, + communityInterfaces: [], + comports: [], newInterfaceName: null, @@ -1228,6 +1217,7 @@ export default { mounted() { this.getConfig(); this.loadComports(); + this.loadCommunityInterfaces(); // check if we are editing an interface const interfaceName = this.$route.query.interface_name; @@ -1263,6 +1253,14 @@ export default { // do nothing if failed to load interfaces } }, + async loadCommunityInterfaces() { + try { + const response = await window.axios.get(`/api/v1/community-interfaces`); + this.communityInterfaces = response.data.interfaces; + } catch { + // do nothing if failed to load interfaces + } + }, async loadInterfaceToEdit(interfaceName) { try { // fetch interfaces diff --git a/meshchatx/src/frontend/components/interfaces/Interface.vue b/meshchatx/src/frontend/components/interfaces/Interface.vue index edce296..c025824 100644 --- a/meshchatx/src/frontend/components/interfaces/Interface.vue +++ b/meshchatx/src/frontend/components/interfaces/Interface.vue @@ -274,7 +274,7 @@ export default { diff --git a/meshchatx/src/frontend/components/messages/AddImageButton.vue b/meshchatx/src/frontend/components/messages/AddImageButton.vue index 795a30d..958af96 100644 --- a/meshchatx/src/frontend/components/messages/AddImageButton.vue +++ b/meshchatx/src/frontend/components/messages/AddImageButton.vue @@ -107,7 +107,9 @@ export default { quality: 0.2, mimeType: "image/webp", success: (result) => { - this.$emit("add-image", result); + // ensure result is a File with the same name as original + const compressedFile = new File([result], file.name, { type: result.type }); + this.$emit("add-image", compressedFile); }, error: (err) => { DialogUtils.alert(err.message); @@ -122,7 +124,9 @@ export default { quality: 0.6, mimeType: "image/webp", success: (result) => { - this.$emit("add-image", result); + // ensure result is a File with the same name as original + const compressedFile = new File([result], file.name, { type: result.type }); + this.$emit("add-image", compressedFile); }, error: (err) => { DialogUtils.alert(err.message); @@ -137,7 +141,9 @@ export default { quality: 0.75, mimeType: "image/webp", success: (result) => { - this.$emit("add-image", result); + // ensure result is a File with the same name as original + const compressedFile = new File([result], file.name, { type: result.type }); + this.$emit("add-image", compressedFile); }, error: (err) => { DialogUtils.alert(err.message); diff --git a/meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue b/meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue new file mode 100644 index 0000000..a062e35 --- /dev/null +++ b/meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue index 0534e5f..c09155c 100644 --- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue +++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue @@ -2,14 +2,14 @@