feat(ui): update user interface with improved icon handling, audio permission checks, and dynamic message icon size adjustments

This commit is contained in:
2026-01-05 20:22:16 -06:00
parent 44608ffb36
commit b46d018b26
8 changed files with 251 additions and 43 deletions

View File

@@ -85,7 +85,7 @@
<MaterialDesignIcon
icon-name="refresh"
class="size-6"
:class="{ 'animate-spin-reverse': isSyncingPropagationNode }"
:class="{ 'animate-spin': isSyncingPropagationNode }"
/>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">{{
isSyncingPropagationNode

View File

@@ -3,13 +3,14 @@
v-if="customImage"
class="rounded-full overflow-hidden shrink-0 flex items-center justify-center"
:class="iconClass || 'size-6'"
:style="iconStyle"
>
<img :src="customImage" class="w-full h-full object-cover" />
</div>
<div
v-else-if="iconName"
class="p-[10%] rounded-full shrink-0 flex items-center justify-center"
:style="{ 'background-color': finalBackgroundColor }"
:style="[iconStyle, { 'background-color': finalBackgroundColor }]"
:class="iconClass || 'size-6'"
>
<MaterialDesignIcon :icon-name="iconName" class="size-full" :style="{ color: finalForegroundColor }" />
@@ -18,6 +19,7 @@
v-else
class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
:class="iconClass || 'size-6'"
:style="iconStyle"
>
<MaterialDesignIcon icon-name="account" class="w-full h-full" />
</div>
@@ -51,6 +53,10 @@ export default {
type: String,
default: "",
},
iconStyle: {
type: Object,
default: () => ({}),
},
},
computed: {
finalForegroundColor() {

View File

@@ -2411,18 +2411,26 @@ export default {
},
async onToggleWebAudio(newVal) {
if (!this.config) return;
const previousValue = this.config.telephone_web_audio_enabled;
this.config.telephone_web_audio_enabled = newVal;
try {
if (newVal) {
const permitted = await this.requestAudioPermission();
if (!permitted) {
this.config.telephone_web_audio_enabled = false;
await this.updateConfig({ telephone_web_audio_enabled: false });
return;
}
}
await this.updateConfig({ telephone_web_audio_enabled: newVal });
if (newVal) {
await this.requestAudioPermission();
await this.startWebAudio();
} else {
this.stopWebAudio();
}
} catch {
// revert on failure
this.config.telephone_web_audio_enabled = !newVal;
this.config.telephone_web_audio_enabled = previousValue;
}
},
async startWebAudio() {
@@ -2430,7 +2438,18 @@ export default {
return;
}
try {
const constraints = this.selectedAudioInputId
await this.refreshAudioDevices();
const hasInputDevices = (this.audioInputDevices || []).length > 0;
if (!hasInputDevices) {
ToastUtils.error(this.$t("call.no_audio_input_found"));
this.config.telephone_web_audio_enabled = false;
await this.updateConfig({ telephone_web_audio_enabled: false });
return;
}
const validDeviceIds = new Set((this.audioInputDevices || []).map((d) => d.deviceId));
const hasSelectedDevice = this.selectedAudioInputId && validDeviceIds.has(this.selectedAudioInputId);
const constraints = hasSelectedDevice
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
: { audio: true };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
@@ -2481,17 +2500,44 @@ export default {
this.refreshAudioDevices();
} catch (err) {
console.error("Web audio failed", err);
ToastUtils.error(this.$t("call.web_audio_not_available"));
const errorKey =
err?.name === "NotFoundError" || err?.name === "OverconstrainedError"
? "call.no_audio_input_found"
: err?.name === "NotAllowedError"
? "call.microphone_permission_denied"
: "call.web_audio_not_available";
ToastUtils.error(this.$t(errorKey));
this.config.telephone_web_audio_enabled = false;
await this.updateConfig({ telephone_web_audio_enabled: false });
this.stopWebAudio();
}
},
async requestAudioPermission() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
const hasAudioInput = devices.some((d) => d.kind === "audioinput");
if (devices.length > 0 && !hasAudioInput) {
ToastUtils.error(this.$t("call.no_audio_input_found"));
return false;
}
const constraints = this.selectedAudioInputId
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
: { audio: true };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
stream.getTracks().forEach((t) => t.stop());
await this.refreshAudioDevices();
return true;
} catch (e) {
console.error("Permission or device request failed", e);
const errorKey =
e?.name === "NotFoundError" || e?.name === "OverconstrainedError"
? "call.no_audio_input_found"
: e?.name === "NotAllowedError"
? "call.microphone_permission_denied"
: "call.web_audio_not_available";
ToastUtils.error(this.$t(errorKey));
return false;
}
},
async refreshAudioDevices() {

View File

@@ -164,8 +164,8 @@
Recently Heard Announces
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Cards appear/disappear as announces are heard. Connected entries show a green
pill; disconnected entries are dimmed with a red label.
Discovery runs continually; heard announces stay listed. Connected entries show
a green pill; disconnected entries are dimmed with a red label.
</div>
</div>
<div class="flex gap-2">
@@ -193,7 +193,7 @@
v-if="sortedDiscoveredInterfaces.length === 0"
class="text-sm text-gray-500 dark:text-gray-300"
>
No discovered interfaces yet.
{{ discoveredEmptyMessage }}
</div>
<div
@@ -627,6 +627,15 @@ export default {
});
return set;
},
discoveredEmptyMessage() {
if (!this.isReticulumRunning) {
return "LXMF/Reticulum is not running; discovery cannot listen for announces.";
}
if (!this.discoveryConfig.discover_interfaces) {
return "Discovery is disabled. Enable it to start listening for announces.";
}
return "Discovery is working, be patient while it waits for announces.";
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
@@ -783,12 +792,40 @@ export default {
async loadDiscoveredInterfaces() {
try {
const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
this.discoveredInterfaces = response.data?.interfaces ?? [];
this.discoveredActive = response.data?.active ?? [];
const incoming = response.data?.interfaces ?? [];
const active = response.data?.active ?? [];
const merged = new Map();
const addOrUpdate = (iface, isNew = false) => {
const key = this.discoveryKey(iface);
const existing =
merged.get(key) || this.discoveredInterfaces.find((i) => this.discoveryKey(i) === key);
const lastHeard = iface.last_heard ?? existing?.last_heard ?? Math.floor(Date.now() / 1000);
merged.set(key, {
...existing,
...iface,
last_heard: lastHeard,
__isNew: isNew || existing?.__isNew,
});
};
this.discoveredInterfaces.forEach((iface) => addOrUpdate(iface, false));
incoming.forEach((iface) => addOrUpdate(iface, true));
this.discoveredInterfaces = Array.from(merged.values());
this.discoveredActive = active;
} catch (e) {
console.log(e);
}
},
discoveryKey(iface) {
return (
iface.discovery_hash ||
`${iface.reachable_on || iface.target_host || iface.remote || iface.listen_ip || iface.name || "unknown"}:${
iface.port || iface.target_port || iface.listen_port || ""
}`
);
},
formatLastHeard(ts) {
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (seconds < 60) return `${seconds}s ago`;

View File

@@ -35,7 +35,8 @@
:icon-background-colour="
selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.background_colour : ''
"
icon-class="size-11"
icon-class="shrink-0"
:icon-style="messageIconStyle"
/>
</div>
@@ -956,7 +957,7 @@
<MaterialDesignIcon
icon-name="sync"
class="size-6"
:class="{ 'animate-spin-reverse': isSyncingPropagationNode }"
:class="{ 'animate-spin': isSyncingPropagationNode }"
/>
</div>
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">{{
@@ -1482,6 +1483,15 @@ export default {
};
},
computed: {
messageIconStyle() {
const size = Number(this.config?.message_icon_size) || 28;
return {
width: `${size}px`,
height: `${size}px`,
minWidth: `${size}px`,
minHeight: `${size}px`,
};
},
isSyncingPropagationNode() {
return [
"path_requested",

View File

@@ -290,7 +290,7 @@
<div v-if="isLoading" class="w-full divide-y divide-gray-100 dark:divide-zinc-800">
<div v-for="i in 6" :key="i" class="p-3 animate-pulse">
<div class="flex gap-3">
<div class="size-10 rounded bg-gray-200 dark:bg-zinc-800"></div>
<div class="rounded bg-gray-200 dark:bg-zinc-800" :style="messageIconStyle"></div>
<div class="flex-1 space-y-2 py-1">
<div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-3/4"></div>
<div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-1/2"></div>
@@ -366,7 +366,8 @@
:icon-background-colour="
conversation.lxmf_user_icon ? conversation.lxmf_user_icon.background_colour : ''
"
icon-class="size-7"
icon-class="shrink-0"
:icon-style="messageIconStyle"
/>
</div>
<div class="mr-auto w-full pr-2 min-w-0">
@@ -559,7 +560,8 @@
:icon-name="peer.lxmf_user_icon?.icon_name"
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
icon-class="size-7"
icon-class="shrink-0"
:icon-style="messageIconStyle"
/>
</div>
<div class="min-w-0 flex-1">
@@ -795,6 +797,10 @@ export default {
allSelected() {
return this.conversations.length > 0 && this.selectedHashes.size === this.conversations.length;
},
messageIconStyle() {
const size = GlobalState.config?.message_icon_size || 28;
return { width: `${size}px`, height: `${size}px` };
},
},
watch: {
foldersExpanded(newVal) {

View File

@@ -14,28 +14,20 @@
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("app.profile") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
{{ config.display_name }}
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<div class="flex-1 min-w-0">
<input
v-model="config.display_name"
type="text"
:placeholder="$t('app.display_name_placeholder')"
class="w-full rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-base font-semibold text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/60 focus:border-blue-500 outline-none transition"
@input="onDisplayNameChange"
/>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
{{ $t("app.manage_identity") }}
</div>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $t("app.manage_identity") }}</div>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<button
type="button"
class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-zinc-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-400/70 transition"
@click="copyValue(config.identity_hash, $t('app.identity_hash'))"
>
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
{{ $t("app.identity_hash") }}
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-x-2 rounded-xl bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 px-4 py-2 text-sm font-semibold text-white shadow hover:shadow-md transition"
@click="copyValue(config.lxmf_address_hash, $t('app.lxmf_address'))"
>
<MaterialDesignIcon icon-name="account-plus" class="w-4 h-4" />
{{ $t("app.lxmf_address") }}
</button>
</div>
</div>
<transition name="fade">
@@ -280,6 +272,22 @@
</div>
</button>
<button
type="button"
class="btn-maintenance border-emerald-200 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
@click="clearLxmfIcons"
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="account-off" class="size-4" />
{{ $t("maintenance.clear_lxmf_icons") }}
</div>
<div class="text-xs opacity-80">
{{ $t("maintenance.clear_lxmf_icons_desc") }}
</div>
</div>
</button>
<button
type="button"
class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
@@ -621,15 +629,59 @@
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Size</div>
<div class="text-xs font-mono text-blue-500 dark:text-blue-400">
{{ config.message_icon_size || 28 }}px
</div>
</div>
<div class="flex items-center gap-3">
<MaterialDesignIcon icon-name="account-outline" class="text-gray-400" />
<input
v-model.number="config.message_icon_size"
type="range"
min="16"
max="64"
step="1"
class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
@input="onMessageIconSizeChange"
/>
<MaterialDesignIcon icon-name="account" class="text-gray-500 dark:text-gray-300" />
</div>
</div>
<div
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2"
class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2"
>
<div>{{ $t("app.live_preview") }}</div>
<div
:style="messageIconPreviewStyle"
class="flex items-center justify-center shrink-0 rounded-full bg-gray-100 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700"
>
<LxmfUserIcon
:key="config.message_icon_size"
icon-name="account"
icon-class="w-full h-full"
icon-foreground-colour="#374151"
icon-background-colour="#e5e7eb"
/>
</div>
<div class="flex-1 min-w-0 space-y-0.5">
<div
class="font-semibold text-gray-900 dark:text-gray-100"
:style="previewTextStyle"
>
Preview Name
</div>
<div class="text-gray-600 dark:text-gray-400 truncate" :style="previewTextStyle">
Hey there, this is how text and icons will look.
</div>
</div>
<span
class="inline-flex items-center gap-1 text-blue-500 dark:text-blue-300 text-xs font-semibold uppercase"
>
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
{{ $t("app.realtime") }}
{{ $t("app.live_preview") }}
</span>
</div>
</div>
@@ -1168,6 +1220,7 @@ import Toggle from "../forms/Toggle.vue";
import ShortcutRecorder from "./ShortcutRecorder.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
export default {
name: "SettingsPage",
@@ -1175,6 +1228,7 @@ export default {
MaterialDesignIcon,
Toggle,
ShortcutRecorder,
LxmfUserIcon,
},
data() {
return {
@@ -1193,10 +1247,13 @@ export default {
lxmf_local_propagation_node_enabled: null,
lxmf_preferred_propagation_node_destination_hash: null,
archives_max_storage_gb: 1,
backup_max_count: 5,
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
blackhole_integration_enabled: true,
message_font_size: 14,
message_icon_size: 28,
telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50,
gitea_base_url: "https://git.quad4.io",
@@ -1256,6 +1313,7 @@ export default {
"app.light_theme",
"app.dark_theme",
"Message Font Size",
"Icon Size",
"app.live_preview",
"app.realtime",
],
@@ -1337,6 +1395,20 @@ export default {
}
return this.config;
},
previewTextStyle() {
const size = this.config?.message_font_size || 14;
return { "font-size": `${size}px` };
},
messageIconPreviewStyle() {
const size = Number(this.config?.message_icon_size) || 28;
return {
width: `${size}px`,
height: `${size}px`,
minWidth: `${size}px`,
minHeight: `${size}px`,
transition: "width 120ms linear, height 120ms linear",
};
},
},
beforeUnmount() {
// stop listening for websocket messages
@@ -1452,6 +1524,28 @@ export default {
);
}, 1000);
},
async onDisplayNameChange() {
if (this.saveTimeouts.display_name) clearTimeout(this.saveTimeouts.display_name);
this.saveTimeouts.display_name = setTimeout(async () => {
await this.updateConfig(
{
display_name: this.config.display_name,
},
"display_name"
);
}, 600);
},
async onMessageIconSizeChange() {
if (this.saveTimeouts.message_icon_size) clearTimeout(this.saveTimeouts.message_icon_size);
this.saveTimeouts.message_icon_size = setTimeout(async () => {
await this.updateConfig(
{
message_icon_size: this.config.message_icon_size,
},
"message_icon_size"
);
}, 1000);
},
async onLanguageChange() {
await this.updateConfig(
{
@@ -1780,6 +1874,15 @@ export default {
ToastUtils.error(this.$t("common.error"));
}
},
async clearLxmfIcons() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
await window.axios.delete("/api/v1/maintenance/lxmf-icons");
ToastUtils.success(this.$t("maintenance.lxmf_icons_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
async clearArchives() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {

View File

@@ -83,7 +83,7 @@
<div>
<div class="font-bold text-gray-900 dark:text-white">{{ bot.name }}</div>
<div class="text-xs font-mono text-gray-500">
{{ runningMap[bot.id]?.address || "Not running" }}
{{ bot.address || runningMap[bot.id]?.address || "Not running" }}
</div>
<div class="text-[10px] text-gray-400">
{{ bot.template_id || bot.template }}
@@ -277,7 +277,7 @@ export default {
try {
await window.axios.post("/api/v1/bots/start", {
bot_id: bot.id,
template_id: bot.template_id,
template_id: bot.template_id || bot.template,
name: bot.name,
});
ToastUtils.success(this.$t("bots.bot_started"));