Files
MeshChatX/meshchatx/src/frontend/components/App.vue

1232 lines
57 KiB
Vue

<template>
<div
:class="{ dark: config?.theme === 'dark' }"
class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors"
>
<!-- emergency banner -->
<div
v-if="appInfo?.emergency"
class="relative z-[100] bg-red-600 text-white px-4 py-2 text-center text-sm font-bold shadow-md animate-pulse"
>
<div class="flex items-center justify-center gap-2">
<MaterialDesignIcon icon-name="alert-decagram" class="size-5" />
<span>{{ $t("app.emergency_mode_active") }}</span>
</div>
</div>
<RouterView v-if="$route.name === 'auth'" />
<template v-else>
<div v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50/90 dark:bg-zinc-950">
<RouterView class="flex-1" />
</div>
<template v-else>
<!-- header -->
<div
class="relative z-[60] flex bg-white/80 dark:bg-zinc-900/70 backdrop-blur border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors"
>
<div class="flex w-full px-4">
<button
type="button"
class="sm:hidden my-auto mr-4 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
@click="isSidebarOpen = !isSidebarOpen"
>
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
<div
class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-white/10 border border-gray-200 dark:border-zinc-700 shadow-inner"
>
<img class="w-12 h-12 object-contain p-1.5" :src="logoUrl" />
</div>
<div class="my-auto">
<div
class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 tracking-tight text-lg"
@click="onAppNameClick"
>
{{ $t("app.name") }}
</div>
<div class="hidden sm:block text-sm text-gray-600 dark:text-zinc-300">
{{ $t("app.custom_fork_by") }}
<a
target="_blank"
href="https://github.com/Sudo-Ivan"
class="text-blue-500 dark:text-blue-300 hover:underline"
>Sudo-Ivan</a
>
</div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<button
type="button"
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<MaterialDesignIcon
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
</button>
<LanguageSelector @language-change="onLanguageChange" />
<NotificationBell />
<button
type="button"
class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="$t('app.audio_calls')"
@click="$router.push({ name: 'call' })"
>
<MaterialDesignIcon icon-name="phone" class="w-5 h-5 sm:w-6 sm:h-6" />
</button>
<button type="button" class="hidden sm:flex rounded-full" @click="syncPropagationNode">
<span
class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-sm transition"
>
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
<MaterialDesignIcon icon-name="refresh" class="size-6" />
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">{{
$t("app.sync_messages")
}}</span>
</span>
</button>
<button type="button" class="hidden sm:flex rounded-full" @click="composeNewMessage">
<span
class="flex text-white bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 hover:from-blue-500/90 hover:to-purple-500/90 px-3 py-1.5 rounded-full shadow-md transition"
>
<span>
<MaterialDesignIcon icon-name="email" class="w-6 h-6" />
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-semibold">{{
$t("app.compose")
}}</span>
</span>
</button>
</div>
</div>
</div>
<!-- middle -->
<div
ref="middle"
class="flex flex-1 w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors"
>
<!-- sidebar backdrop for mobile -->
<div
v-if="isSidebarOpen"
class="fixed inset-0 z-[65] bg-black/20 backdrop-blur-sm sm:hidden"
@click="isSidebarOpen = false"
></div>
<!-- sidebar -->
<div
class="fixed inset-y-0 left-0 z-[70] transform transition-all duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
:class="[
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
isSidebarCollapsed ? 'w-16' : 'w-72',
]"
>
<div
class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200/70 bg-white dark:border-zinc-800 dark:bg-zinc-900 backdrop-blur"
>
<!-- toggle button for desktop -->
<div class="hidden sm:flex justify-end p-2 border-b border-gray-100 dark:border-zinc-800">
<button
type="button"
class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors"
@click="isSidebarCollapsed = !isSidebarCollapsed"
>
<MaterialDesignIcon
:icon-name="isSidebarCollapsed ? 'chevron-right' : 'chevron-left'"
class="size-5"
/>
</button>
</div>
<!-- navigation -->
<div class="flex-1">
<ul class="py-3 pr-2 space-y-1">
<!-- messages -->
<li>
<SidebarLink :to="{ name: 'messages' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="message-text"
class="w-6 h-6 text-gray-700 dark:text-white"
/>
</template>
<template #text>
<span>{{ $t("app.messages") }}</span>
<span v-if="unreadConversationsCount > 0" class="ml-auto mr-2">{{
unreadConversationsCount
}}</span>
</template>
</SidebarLink>
</li>
<!-- nomad network -->
<li>
<SidebarLink :to="{ name: 'nomadnetwork' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="earth"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.nomad_network") }}</template>
</SidebarLink>
</li>
<!-- map -->
<li>
<SidebarLink :to="{ name: 'map' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="map"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.map") }}</template>
</SidebarLink>
</li>
<!-- archives -->
<li>
<SidebarLink :to="{ name: 'archives' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="archive"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.archives") }}</template>
</SidebarLink>
</li>
<!-- telephone -->
<li>
<SidebarLink :to="{ name: 'call' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="phone"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.audio_calls") }}</template>
</SidebarLink>
</li>
<!-- interfaces -->
<li>
<SidebarLink :to="{ name: 'interfaces' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="router"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.interfaces") }}</template>
</SidebarLink>
</li>
<!-- network visualiser -->
<li>
<SidebarLink
:to="{ name: 'network-visualiser' }"
:is-collapsed="isSidebarCollapsed"
>
<template #icon>
<MaterialDesignIcon
icon-name="hub"
class="w-6 h-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.network_visualiser") }}</template>
</SidebarLink>
</li>
<!-- tools -->
<li>
<SidebarLink :to="{ name: 'tools' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="wrench"
class="size-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.tools") }}</template>
</SidebarLink>
</li>
<!-- settings -->
<li>
<SidebarLink :to="{ name: 'settings' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="cog"
class="size-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.settings") }}</template>
</SidebarLink>
</li>
<!-- identities -->
<li>
<SidebarLink :to="{ name: 'identities' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="account-multiple"
class="size-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.identities") }}</template>
</SidebarLink>
</li>
<!-- info -->
<li>
<SidebarLink :to="{ name: 'about' }" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
icon-name="information"
class="size-6 text-gray-700 dark:text-gray-200"
/>
</template>
<template #text>{{ $t("app.about") }}</template>
</SidebarLink>
</li>
</ul>
</div>
<div>
<!-- my identity -->
<div
v-if="config"
class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur"
>
<div
class="flex text-gray-700 p-3 cursor-pointer"
@click="isShowingMyIdentitySection = !isShowingMyIdentitySection"
>
<div class="my-auto mr-2 shrink-0">
<RouterLink :to="{ name: 'profile.icon' }" @click.stop>
<LxmfUserIcon
:icon-name="config?.lxmf_user_icon_name"
:icon-foreground-colour="config?.lxmf_user_icon_foreground_colour"
:icon-background-colour="config?.lxmf_user_icon_background_colour"
icon-class="size-7"
/>
</RouterLink>
</div>
<div v-if="!isSidebarCollapsed" class="my-auto dark:text-white truncate">
{{ $t("app.my_identity") }}
</div>
<div v-if="!isSidebarCollapsed" class="my-auto ml-auto shrink-0">
<button
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
@click.stop="saveIdentitySettings"
>
{{ $t("common.save") }}
</button>
</div>
</div>
<div
v-if="isShowingMyIdentitySection && !isSidebarCollapsed"
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
>
<div class="p-2">
<input
v-model="displayName"
type="text"
:placeholder="$t('app.display_name_placeholder')"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
/>
</div>
<div class="p-2 dark:border-zinc-900 overflow-hidden text-xs">
<div>{{ $t("app.identity_hash") }}</div>
<div
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono"
:title="config.identity_hash"
>
{{ config.identity_hash }}
</div>
</div>
<div class="p-2 dark:border-zinc-900 overflow-hidden text-xs">
<div>{{ $t("app.lxmf_address") }}</div>
<div
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono"
:title="config.lxmf_address_hash"
>
{{ config.lxmf_address_hash }}
</div>
</div>
</div>
</div>
<!-- auto announce -->
<div
v-if="config"
class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800"
>
<div
class="flex text-gray-700 p-3 cursor-pointer dark:text-white"
@click="isShowingAnnounceSection = !isShowingAnnounceSection"
>
<div class="my-auto mr-2 shrink-0">
<MaterialDesignIcon icon-name="radio" class="size-6" />
</div>
<div v-if="!isSidebarCollapsed" class="my-auto truncate">
{{ $t("app.announce") }}
</div>
<div v-if="!isSidebarCollapsed" class="ml-auto shrink-0">
<button
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
@click.stop="sendAnnounce"
>
{{ $t("app.announce_now") }}
</button>
</div>
</div>
<div
v-if="isShowingAnnounceSection && !isSidebarCollapsed"
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
>
<div class="p-2 dark:border-zinc-800">
<select
v-model="config.auto_announce_interval_seconds"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
@change="onAnnounceIntervalSecondsChange"
>
<option value="0">{{ $t("app.disabled") }}</option>
<option value="900">Every 15 Minutes</option>
<option value="1800">Every 30 Minutes</option>
<option value="3600">Every 1 Hour</option>
<option value="10800">Every 3 Hours</option>
<option value="21600">Every 6 Hours</option>
<option value="43200">Every 12 Hours</option>
<option value="86400">Every 24 Hours</option>
</select>
<div class="text-[10px] text-gray-700 dark:text-zinc-100 mt-1">
<span v-if="config.last_announced_at">{{
$t("app.last_announced", {
time: formatSecondsAgo(config.last_announced_at),
})
}}</span>
<span v-else>{{ $t("app.last_announced_never") }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-1 min-w-0 overflow-hidden">
<RouterView class="flex-1 min-w-0 h-full" />
</div>
</div>
</template>
</template>
<CallOverlay
v-if="
(activeCall || isCallEnded || wasDeclined || initiationStatus) &&
!$route.meta.isPopout &&
(!['call', 'call-popout'].includes($route.name) || activeCallTab !== 'phone') &&
(!config?.desktop_open_calls_in_separate_window || !ElectronUtils.isElectron())
"
:active-call="activeCall || lastCall"
:is-ended="isCallEnded"
:was-declined="wasDeclined"
:voicemail-status="voicemailStatus"
:initiation-status="initiationStatus"
@hangup="onOverlayHangup"
@toggle-mic="onToggleMic"
@toggle-speaker="onToggleSpeaker"
/>
<Toast />
<ConfirmDialog />
<CommandPalette />
<IntegrityWarningModal />
<ChangelogModal ref="changelogModal" :app-version="appInfo?.version" />
<TutorialModal ref="tutorialModal" />
<!-- identity switching overlay -->
<transition name="fade-blur">
<div
v-if="isSwitchingIdentity"
class="fixed inset-0 z-[200] flex items-center justify-center bg-white/10 dark:bg-black/10 backdrop-blur-md"
>
<div class="flex flex-col items-center">
<div class="relative">
<div
class="w-20 h-20 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
></div>
<div class="absolute inset-0 flex items-center justify-center">
<MaterialDesignIcon icon-name="account-sync" class="w-8 h-8 text-blue-500 animate-pulse" />
</div>
</div>
<div class="mt-6 text-xl font-bold text-gray-900 dark:text-white tracking-tight">
{{ $t("app.switching_identity") }}
</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $t("app.loading_identity") }}</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import SidebarLink from "./SidebarLink.vue";
import DialogUtils from "../js/DialogUtils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
import Utils from "../js/Utils";
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 {
name: "App",
components: {
LxmfUserIcon,
SidebarLink,
Toast,
ConfirmDialog,
MaterialDesignIcon,
NotificationBell,
LanguageSelector,
CallOverlay,
CommandPalette,
IntegrityWarningModal,
ChangelogModal,
TutorialModal,
},
data() {
return {
logoUrl,
ElectronUtils,
reloadInterval: null,
appInfoInterval: null,
isShowingMyIdentitySection: true,
isShowingAnnounceSection: true,
isSidebarOpen: false,
isSidebarCollapsed: false,
isSwitchingIdentity: false,
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: {
currentPopoutType() {
if (this.$route?.meta?.popoutType) {
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.currentPopoutType != null;
},
unreadConversationsCount() {
return GlobalState.unreadConversationsCount;
},
isSyncingPropagationNode() {
return [
"path_requested",
"link_establishing",
"link_established",
"request_sent",
"receiving",
"response_received",
"complete",
].includes(this.propagationNodeStatus?.state);
},
activeCallTab() {
return GlobalState.activeCallTab;
},
},
watch: {
$route() {
this.isSidebarOpen = false;
},
config: {
handler(newConfig) {
if (newConfig && newConfig.language) {
this.$i18n.locale = newConfig.language;
}
if (newConfig && newConfig.custom_ringtone_enabled !== undefined) {
this.updateRingtonePlayer();
}
if (newConfig && newConfig.theme) {
if (newConfig.theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
},
deep: true,
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.appInfoInterval);
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.stopRingtone();
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
// listen for identity switching events
GlobalEmitter.on("identity-switching-start", () => {
this.isSwitchingIdentity = true;
// safety timeout to hide overlay if something goes wrong
setTimeout(() => {
if (this.isSwitchingIdentity) {
this.isSwitchingIdentity = false;
}
}, 10000);
});
GlobalEmitter.on("sync-propagation-node", () => {
this.syncPropagationNode();
});
GlobalEmitter.on("keyboard-shortcut", (action) => {
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();
this.updatePropagationNodeStatus();
}, 1000);
this.appInfoInterval = setInterval(() => {
this.getAppInfo();
}, 15000);
},
methods: {
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
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") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
break;
}
case "keyboard_shortcuts": {
KeyboardShortcuts.setShortcuts(json.shortcuts);
break;
}
case "announced": {
// we just announced, update config so we can show the new last updated at
this.getConfig();
break;
}
case "telephone_ringing": {
if (this.config?.do_not_disturb_enabled) {
break;
}
NotificationUtils.showIncomingCallNotification();
this.updateTelephoneStatus();
this.playRingtone();
break;
}
case "telephone_missed_call": {
NotificationUtils.showMissedCallNotification(
json.remote_identity_name || json.remote_identity_hash
);
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
);
this.updateTelephoneStatus();
break;
}
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
// only for incoming messages
if (!document.hasFocus() && json.lxmf_message?.is_incoming === true) {
NotificationUtils.showNewMessageNotification(
json.remote_identity_name,
json.lxmf_message?.content
);
}
break;
}
case "identity_switched": {
ToastUtils.success(`Switched to identity: ${json.display_name}`);
// reset global state
GlobalState.unreadConversationsCount = 0;
// update local state
await this.getConfig();
await this.updateRingtonePlayer();
await this.getAppInfo();
// hide loading overlay
this.isSwitchingIdentity = false;
// if we are on identities page, we might want to refresh it
GlobalEmitter.emit("identity-switched", json);
break;
}
}
},
async getAppInfo() {
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 !== "999.999.999" &&
this.appInfo.changelog_seen_version !== this.appInfo.version
) {
// show changelog if version changed and not silenced forever
this.$refs.changelogModal.show();
}
}
} catch (e) {
// do nothing if failed to load app info
console.log(e);
}
},
async getConfig() {
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");
} else {
document.documentElement.classList.remove("dark");
}
}
} catch (e) {
// do nothing if failed to load config
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({
type: "keyboard_shortcuts.get",
})
);
},
async sendAnnounce() {
try {
await window.axios.get(`/api/v1/announce`);
} catch (e) {
ToastUtils.error("failed to announce");
console.log(e);
}
// fetch config so it updates last announced timestamp
await this.getConfig();
},
async updateConfig(config, label = null) {
try {
WebSocketConnection.send(
JSON.stringify({
type: "config.set",
config: config,
})
);
if (label) {
ToastUtils.success(
this.$t("app.setting_auto_saved", {
label: this.$t(`app.${label.toLowerCase().replace(/ /g, "_")}`),
})
);
}
} catch (e) {
console.error(e);
}
},
async saveIdentitySettings() {
await this.updateConfig({
display_name: this.displayName,
});
},
async onAnnounceIntervalSecondsChange() {
await this.updateConfig(
{
auto_announce_interval_seconds: this.config.auto_announce_interval_seconds,
},
"announce_interval"
);
},
async toggleTheme() {
if (!this.config) {
return;
}
const newTheme = this.config.theme === "dark" ? "light" : "dark";
await this.updateConfig(
{
theme: newTheme,
},
"theme"
);
},
async onLanguageChange(langCode) {
await this.updateConfig(
{
language: langCode,
},
"language"
);
this.$i18n.locale = langCode;
},
async composeNewMessage() {
// go to messages route
await this.$router.push({ name: "messages" });
// emit global event handled by MessagesPage
GlobalEmitter.emit("compose-new-message");
},
async syncPropagationNode() {
// ask to stop syncing if already syncing
if (this.isSyncingPropagationNode) {
if (await DialogUtils.confirm(this.$t("app.stop_sync_confirm"))) {
await this.stopSyncingPropagationNode();
}
return;
}
// request sync
try {
await axios.get("/api/v1/lxmf/propagation-node/sync");
} catch (e) {
const errorMessage = e.response?.data?.message ?? this.$t("app.sync_error_generic");
ToastUtils.error(errorMessage);
return;
}
// update propagation status
await this.updatePropagationNodeStatus();
// wait until sync has finished
const syncFinishedInterval = setInterval(() => {
// do nothing if still syncing
if (this.isSyncingPropagationNode) {
return;
}
// finished syncing, stop checking
clearInterval(syncFinishedInterval);
// show result
const status = this.propagationNodeStatus?.state;
const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0;
if (status === "complete" || status === "idle") {
ToastUtils.success(this.$t("app.sync_complete", { count: messagesReceived }));
} else {
ToastUtils.error(this.$t("app.sync_error", { status: status }));
}
}, 500);
},
async stopSyncingPropagationNode() {
// stop sync
try {
await axios.get("/api/v1/lxmf/propagation-node/stop-sync");
} catch {
// do nothing on error
}
// update propagation status
await this.updatePropagationNodeStatus();
},
async updatePropagationNodeStatus() {
try {
const response = await axios.get("/api/v1/lxmf/propagation-node/status");
this.propagationNodeStatus = response.data.propagation_node_status;
} catch {
// do nothing on error
}
},
formatSecondsAgo: function (seconds) {
return Utils.formatSecondsAgo(seconds);
},
async updateRingtonePlayer() {
// Stop current player if any
if (this.ringtonePlayer) {
this.ringtonePlayer.pause();
this.ringtonePlayer = null;
}
if (this.config?.custom_ringtone_enabled) {
try {
const response = await window.axios.get("/api/v1/telephone/ringtones/status");
const status = response.data;
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);
}
}
},
playRingtone() {
if (this.ringtonePlayer) {
if (this.ringtonePlayer.paused) {
this.ringtonePlayer.play().catch((e) => {
console.log("Failed to play custom ringtone:", e);
});
}
}
},
stopRingtone() {
if (this.ringtonePlayer) {
try {
this.ringtonePlayer.pause();
this.ringtonePlayer.currentTime = 0;
} catch {
// ignore errors during pause
}
}
},
async updateTelephoneStatus() {
try {
// 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 = newCall;
this.voicemailStatus = response.data.voicemail;
this.initiationStatus = response.data.initiation_status;
this.initiationTargetHash = response.data.initiation_target_hash;
// 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", "MeshChatXCallWindow", "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
if (oldCall != null && this.activeCall == null) {
this.lastCall = oldCall;
// 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);
} else if (this.activeCall != null) {
// if a new call starts, clear ended state
this.isCallEnded = false;
this.wasDeclined = false;
this.lastCall = null;
if (this.endedTimeout) clearTimeout(this.endedTimeout);
} else if (!this.endedTimeout) {
// If no call and no ended state timeout active, ensure everything is reset
this.isCallEnded = false;
this.wasDeclined = false;
this.lastCall = null;
}
} catch {
// do nothing on error
}
},
onOverlayHangup() {
if (this.activeCall && this.activeCall.is_incoming && this.activeCall.status === 4) {
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({
top: 0,
left: 0,
behavior: "smooth",
});
},
handleProtocolLink(url) {
try {
// lxmf://<hash> or rns://<hash>
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":
this.$router.push({ name: "messages" });
break;
case "nav_nomad":
this.$router.push({ name: "nomadnetwork" });
break;
case "nav_map":
this.$router.push({ name: "map" });
break;
case "nav_paper":
this.$router.push({ name: "paper-message" });
break;
case "nav_archives":
this.$router.push({ name: "archives" });
break;
case "nav_calls":
this.$router.push({ name: "call" });
break;
case "nav_settings":
this.$router.push({ name: "settings" });
break;
case "compose_message":
this.composeNewMessage();
break;
case "sync_messages":
this.syncPropagationNode();
break;
case "command_palette":
// Command palette handles its own shortcut but we emit it just in case
break;
case "toggle_sidebar":
this.isSidebarCollapsed = !this.isSidebarCollapsed;
break;
}
},
},
};
</script>
<style>
.banished-overlay {
@apply absolute inset-0 z-[100] flex items-center justify-center overflow-hidden pointer-events-none rounded-[inherit];
background: rgba(220, 38, 38, 0.12);
backdrop-filter: blur(3px) saturate(180%);
}
.banished-text {
@apply font-black tracking-[0.3em] uppercase pointer-events-none opacity-40;
font-size: clamp(1.5rem, 8vw, 6rem);
color: #dc2626;
transform: rotate(-12deg);
text-shadow: 0 0 15px rgba(220, 38, 38, 0.4);
border: 0.2em solid #dc2626;
padding: 0.15em 0.4em;
border-radius: 0.15em;
background: rgba(255, 255, 255, 0.05);
}
.fade-blur-enter-active,
.fade-blur-leave-active {
transition: all 0.5s ease;
}
.fade-blur-enter-from,
.fade-blur-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
</style>