feat(components): update AboutPage and ConversationViewer with async file handling; add max hops filter to NetworkVisualiser and improve SettingsPage styling

This commit is contained in:
Ivan
2026-04-13 18:29:33 -05:00
parent 92b84f0e3d
commit 559f6bc8f2
4 changed files with 260 additions and 42 deletions

View File

@@ -1257,16 +1257,28 @@ export default {
showTutorial() {
GlobalEmitter.emit("show-tutorial");
},
showReticulumConfigFile() {
async showReticulumConfigFile() {
const reticulumConfigPath = this.appInfo.reticulum_config_path;
if (reticulumConfigPath) {
ElectronUtils.showPathInFolder(reticulumConfigPath);
if (!reticulumConfigPath) {
return;
}
const ok = await ElectronUtils.revealPathInFolderOrCopy(reticulumConfigPath, () =>
ToastUtils.success(this.$t("common.copied")),
);
if (!ok) {
DialogUtils.alert(reticulumConfigPath);
}
},
showDatabaseFile() {
async showDatabaseFile() {
const databasePath = this.appInfo.database_path;
if (databasePath) {
ElectronUtils.showPathInFolder(databasePath);
if (!databasePath) {
return;
}
const ok = await ElectronUtils.revealPathInFolderOrCopy(databasePath, () =>
ToastUtils.success(this.$t("common.copied")),
);
if (!ok) {
DialogUtils.alert(databasePath);
}
},
formatBytes: function (bytes) {

View File

@@ -529,7 +529,7 @@
: isOutboundPathfindingBubble(entry.items[0])
? 'bg-gray-200 dark:bg-zinc-700 text-gray-900 dark:text-zinc-100 border border-gray-300 dark:border-zinc-600 shadow-sm'
: entry.items[0].is_outbound
? 'shadow-sm'
? outboundBubbleSurfaceClass(entry.items[0])
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm',
]"
:style="bubbleStyles(entry.items[0])"
@@ -548,12 +548,17 @@
{{ formatTimeAgo(entry.items[0].lxmf_message.created_at) }}
</span>
<div v-if="entry.items[0].is_outbound" class="flex items-center gap-1">
<span
v-if="isOpportunisticDeferredDelivery(entry.items[0].lxmf_message)"
class="text-[9px] font-bold uppercase tracking-wider text-amber-200"
>
{{ $t("messages.opportunistic_deferred_label") }}
</span>
<span
v-if="isOpportunisticDeferredDelivery(entry.items[0].lxmf_message)"
class="text-[9px] font-bold uppercase tracking-wider"
:class="
isThemeOutboundBubble(entry.items[0])
? 'text-amber-800 dark:text-amber-300'
: 'text-amber-200'
"
>
{{ $t("messages.opportunistic_deferred_label") }}
</span>
<span
v-else-if="
['failed', 'cancelled', 'rejected'].includes(
@@ -576,7 +581,8 @@
<MaterialDesignIcon
v-if="entry.items[0].lxmf_message.state === 'delivered'"
icon-name="check-all"
class="size-3 text-blue-300"
class="size-3"
:class="outboundBubbleDeliveredIconClass(entry.items[0])"
title="Delivered"
/>
<MaterialDesignIcon
@@ -586,7 +592,8 @@
)
"
icon-name="check"
class="size-3 text-white/90"
class="size-3"
:class="outboundBubbleSentCheckIconClass(entry.items[0])"
:title="
entry.items[0].lxmf_message.state === 'propagated'
? 'Sent to propagation node'
@@ -623,7 +630,8 @@
<MaterialDesignIcon
v-else-if="isOutboundPendingForUi(entry.items[0])"
icon-name="check"
class="size-3 text-white/90 opacity-50"
class="size-3"
:class="outboundBubblePendingCheckIconClass(entry.items[0])"
:title="$t('messages.sending_ellipsis')"
/>
<div
@@ -652,11 +660,7 @@
<div
v-if="entry.items[0].is_actions_expanded"
class="border-t px-4 py-2.5 rounded-b-2xl rounded-t-md w-full max-w-[min(280px,85vw)]"
:class="
entry.items[0].is_outbound
? 'border-white/20 bg-white/10'
: 'border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50'
"
:class="outboundExpandedActionsShellClass(entry.items[0])"
>
<div class="flex items-center gap-2">
<button
@@ -746,7 +750,7 @@
: isOutboundPathfindingBubble(chatItem)
? 'bg-gray-200 dark:bg-zinc-700 text-gray-900 dark:text-zinc-100 border border-gray-300 dark:border-zinc-600 shadow-sm'
: chatItem.is_outbound
? 'shadow-sm'
? outboundBubbleSurfaceClass(chatItem)
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm',
]"
:style="bubbleStyles(chatItem)"
@@ -755,11 +759,7 @@
<button
type="button"
class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 dark:text-zinc-500"
:class="
chatItem.is_outbound
? 'hover:bg-white/20'
: 'hover:bg-gray-200 dark:hover:bg-zinc-700'
"
:class="outboundMessageMenuButtonHoverClass(chatItem)"
:title="$t('messages.message_actions')"
@click.stop="onMessageContextMenu($event, chatItem)"
>
@@ -796,7 +796,9 @@
class="flex items-center gap-1.5 text-xs font-medium mb-1"
:class="
chatItem.is_outbound
? 'text-orange-200'
? isThemeOutboundBubble(chatItem)
? 'text-orange-800 dark:text-orange-300'
: 'text-orange-200'
: 'text-orange-700 dark:text-orange-300'
"
>
@@ -813,6 +815,13 @@
!shouldHideAutoImageCaption(chatItem)
"
class="leading-relaxed break-words [word-break:break-word] min-w-0 markdown-content"
:class="{
'markdown-content--outbound-theme':
chatItem.is_outbound && isThemeOutboundBubble(chatItem),
'markdown-content--outbound-solid':
chatItem.is_outbound && !isThemeOutboundBubble(chatItem),
'markdown-content--inbound': !chatItem.is_outbound,
}"
:style="{
'font-family': 'inherit',
'font-size': (config?.message_font_size || 14) + 'px',
@@ -998,7 +1007,7 @@
class="flex items-center gap-3 border rounded-lg px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
:class="
chatItem.is_outbound
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
? outboundEmbeddedCardClass(chatItem)
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300 border-gray-200/60 dark:border-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
@click.stop
@@ -1014,7 +1023,7 @@
class="text-[10px] font-normal"
:class="
chatItem.is_outbound
? 'text-white/60'
? outboundEmbeddedSecondaryTextClass(chatItem)
: 'text-gray-500 dark:text-zinc-400'
"
>
@@ -1035,7 +1044,7 @@
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="
chatItem.is_outbound
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
? outboundEmbeddedCardClass(chatItem)
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
"
>
@@ -1061,7 +1070,7 @@
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="
chatItem.is_outbound
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
? outboundEmbeddedCardClass(chatItem)
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
"
@click="viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
@@ -1166,7 +1175,12 @@
<div v-if="chatItem.is_outbound" class="flex items-center gap-1">
<span
v-if="isOpportunisticDeferredDelivery(chatItem.lxmf_message)"
class="text-[9px] font-bold uppercase tracking-wider text-amber-200"
class="text-[9px] font-bold uppercase tracking-wider"
:class="
isThemeOutboundBubble(chatItem)
? 'text-amber-800 dark:text-amber-300'
: 'text-amber-200'
"
>
{{ $t("messages.opportunistic_deferred_label") }}
</span>
@@ -1194,7 +1208,8 @@
<MaterialDesignIcon
v-if="chatItem.lxmf_message.state === 'delivered'"
icon-name="check-all"
class="size-3 text-blue-300"
class="size-3"
:class="outboundBubbleDeliveredIconClass(chatItem)"
title="Delivered"
/>
<!-- sent: single check (include unknown for initial outbound when server confirmed creation) -->
@@ -1203,7 +1218,8 @@
['sent', 'propagated', 'unknown'].includes(chatItem.lxmf_message.state)
"
icon-name="check"
class="size-3 text-white/90"
class="size-3"
:class="outboundBubbleSentCheckIconClass(chatItem)"
:title="
chatItem.lxmf_message.state === 'propagated'
? 'Sent to propagation node'
@@ -1239,7 +1255,8 @@
<MaterialDesignIcon
v-else-if="isOutboundPendingForUi(chatItem)"
icon-name="check"
class="size-3 text-white/90 opacity-50"
class="size-3"
:class="outboundBubblePendingCheckIconClass(chatItem)"
:title="$t('messages.sending_ellipsis')"
/>
<div
@@ -1271,11 +1288,7 @@
<div
v-if="chatItem.is_actions_expanded"
class="border-t px-4 py-2.5"
:class="
chatItem.is_outbound
? 'border-white/20 bg-white/10'
: 'border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50'
"
:class="outboundExpandedActionsShellClass(chatItem)"
>
<div class="flex items-center gap-2">
<button
@@ -2375,9 +2388,18 @@ export default {
compactSendLayout() {
return this.windowWidth < 640;
},
usesThemeOutboundBubbleColor() {
const c = GlobalState?.config?.message_outbound_bubble_color;
if (c == null || String(c).trim() === "") {
return true;
}
return String(c).trim().toLowerCase() === "#4f46e5";
},
bubbleStyles() {
void GlobalState.detailedOutboundSendStatus;
void this.sendStatusUiMs;
void this.usesThemeOutboundBubbleColor;
const useThemeOutbound = this.usesThemeOutboundBubbleColor;
return (chatItem) => {
const styles = {};
const cfg = GlobalState?.config;
@@ -2396,6 +2418,9 @@ export default {
} else if (chatItem.is_outbound) {
if (chatItem.lxmf_message?._pendingPathfinding) {
if (!this.showRichOutboundPendingUi(chatItem)) {
if (useThemeOutbound) {
return {};
}
const color = cfg?.message_outbound_bubble_color || "#4f46e5";
styles["background-color"] = color;
styles["color"] = "#ffffff";
@@ -2403,6 +2428,9 @@ export default {
}
return {};
}
if (useThemeOutbound) {
return {};
}
const color = cfg?.message_outbound_bubble_color || "#4f46e5";
styles["background-color"] = color;
styles["color"] = "#ffffff";
@@ -3852,6 +3880,34 @@ export default {
this.sendStatusUiMs = Date.now();
}, 1000);
},
isThemeOutboundBubble(chatItem) {
if (!chatItem?.is_outbound) {
return false;
}
const st = chatItem.lxmf_message?.state;
if (["cancelled", "failed"].includes(st)) {
return false;
}
return this.usesThemeOutboundBubbleColor;
},
outboundBubbleSurfaceClass(chatItem) {
if (!chatItem?.is_outbound) {
return "";
}
if (["cancelled", "failed"].includes(chatItem.lxmf_message.state)) {
return "";
}
if (chatItem.lxmf_message.is_spam) {
return "";
}
if (this.isOutboundPathfindingBubble(chatItem)) {
return "";
}
if (!this.usesThemeOutboundBubbleColor) {
return "shadow-sm";
}
return "shadow-sm bg-sky-100 text-slate-900 border border-sky-200/90 dark:bg-sky-950/45 dark:text-sky-50 dark:border-sky-800/55";
},
outboundBubbleFooterTimeClass(chatItem) {
if (!chatItem.is_outbound) {
return "text-gray-500 dark:text-zinc-400";
@@ -3859,12 +3915,18 @@ export default {
if (this.isOutboundPathfindingBubble(chatItem)) {
return "text-gray-600 dark:text-zinc-400";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-700/90 dark:text-sky-200/85";
}
return "text-white/90";
},
outboundSendingStatusIconClass(chatItem) {
if (this.isOutboundPathfindingBubble(chatItem)) {
return "text-gray-600 dark:text-zinc-400";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-700 dark:text-sky-300";
}
return "text-white/90";
},
outboundReplySnippetTitleClass(chatItem) {
@@ -3874,6 +3936,9 @@ export default {
if (this.isOutboundPathfindingBubble(chatItem)) {
return "text-gray-700 dark:text-gray-300";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-800 dark:text-sky-200";
}
return "text-white/80";
},
outboundAttachmentCaptionClass(chatItem) {
@@ -3883,8 +3948,65 @@ export default {
if (this.isOutboundPathfindingBubble(chatItem)) {
return "text-gray-600 dark:text-zinc-400";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-800 dark:text-sky-200";
}
return "text-white";
},
outboundBubbleDeliveredIconClass(chatItem) {
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-600 dark:text-sky-400";
}
return "text-blue-300";
},
outboundBubbleSentCheckIconClass(chatItem) {
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-700 dark:text-sky-300";
}
return "text-white/90";
},
outboundBubblePendingCheckIconClass(chatItem) {
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-700 dark:text-sky-300 opacity-50";
}
return "text-white/90 opacity-50";
},
outboundEmbeddedCardClass(chatItem) {
if (!chatItem?.is_outbound) {
return "";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "bg-sky-900/10 text-sky-900 border-sky-300/45 hover:bg-sky-900/14 dark:bg-white/10 dark:text-sky-50 dark:border-sky-700/45 dark:hover:bg-white/15";
}
return "bg-white/20 text-white border-white/20 hover:bg-white/30";
},
outboundEmbeddedSecondaryTextClass(chatItem) {
if (!chatItem?.is_outbound) {
return "";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "text-sky-800/75 dark:text-sky-200/75";
}
return "text-white/60";
},
outboundExpandedActionsShellClass(chatItem) {
if (!chatItem?.is_outbound) {
return "border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "border-sky-200/70 dark:border-sky-800/50 bg-sky-50/40 dark:bg-sky-950/35";
}
return "border-white/20 bg-white/10";
},
outboundMessageMenuButtonHoverClass(chatItem) {
if (!chatItem?.is_outbound) {
return "hover:bg-gray-200 dark:hover:bg-zinc-700";
}
if (this.isThemeOutboundBubble(chatItem)) {
return "hover:bg-sky-900/10 dark:hover:bg-white/10";
}
return "hover:bg-white/20";
},
outboundBubbleStatusHoverTitle(lxmfMessage) {
if (!lxmfMessage) {
return "";
@@ -5062,4 +5184,26 @@ export default {
.markdown-content :deep(h3) {
line-height: 1.2;
}
.markdown-content :deep(a) {
color: #0369a1;
text-decoration: underline;
text-underline-offset: 2px;
}
.dark .markdown-content :deep(a) {
color: #7dd3fc;
}
.markdown-content--outbound-theme :deep(a) {
color: #075985;
}
.dark .markdown-content--outbound-theme :deep(a) {
color: #bae6fd;
}
.markdown-content--outbound-solid :deep(a) {
color: #dbeafe;
}
</style>

View File

@@ -152,6 +152,30 @@
<Toggle id="enable-physics" v-model="enablePhysics" />
</div>
<!-- max hops filter -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
for="hop-filter-slider"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>{{ $t("visualiser.max_hops_filter") }}</label
>
<span
class="text-xs font-bold text-blue-600 dark:text-blue-400 tabular-nums min-w-[4rem] text-right"
>{{ hopFilterSlider === 0 ? $t("visualiser.all") : hopFilterSlider }}</span
>
</div>
<input
id="hop-filter-slider"
v-model.number="hopFilterSlider"
type="range"
min="0"
:max="hopSliderMax"
step="1"
class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-gray-200 dark:bg-zinc-700 accent-blue-600 dark:accent-blue-500"
/>
</div>
<!-- stats -->
<div class="grid grid-cols-2 gap-3 pt-2">
<div
@@ -311,6 +335,8 @@ export default {
pageSize: 1000,
searchQuery: "",
hopFilterSlider: 0,
_hopFilterDebounce: null,
abortController: new AbortController(),
currentLOD: "high",
};
@@ -322,6 +348,17 @@ export default {
offlineInterfaces() {
return this.interfaces.filter((i) => !i.status);
},
hopSliderMax() {
let m = 0;
for (const e of this.pathTable) {
if (e.hops != null && e.hops > m) m = e.hops;
}
return Math.min(256, Math.max(1, m));
},
hopFilterMax() {
if (this.hopFilterSlider === 0) return null;
return this.hopFilterSlider;
},
},
watch: {
autoReload(val) {
@@ -354,6 +391,18 @@ export default {
// we don't want to trigger a full update from server, just re-run the filtering on existing data
this.processVisualization();
},
hopSliderMax() {
if (this.hopFilterSlider > this.hopSliderMax) {
this.hopFilterSlider = this.hopSliderMax;
}
},
hopFilterSlider() {
if (this._hopFilterDebounce) clearTimeout(this._hopFilterDebounce);
this._hopFilterDebounce = setTimeout(() => {
this._hopFilterDebounce = null;
this.processVisualization();
}, 80);
},
},
beforeUnmount() {
if (this.abortController) {
@@ -368,6 +417,10 @@ export default {
this.stopOrbit();
this.stopBouncingBalls();
clearInterval(this.reloadInterval);
if (this._hopFilterDebounce) {
clearTimeout(this._hopFilterDebounce);
this._hopFilterDebounce = null;
}
if (this.network) {
this.network.destroy();
}
@@ -1210,6 +1263,14 @@ export default {
continue;
}
if (
this.hopFilterMax != null &&
disc.hops != null &&
disc.hops > this.hopFilterMax
) {
continue;
}
const isConnected = this.discoveredActive.some((a) => {
const aHost = a.target_host || a.remote || a.listen_ip;
const aPort = a.target_port || a.listen_port;
@@ -1284,6 +1345,7 @@ export default {
for (const entry of chunk) {
this.loadedNodesCount++;
if (entry.hops == null) continue;
if (this.hopFilterMax != null && entry.hops > this.hopFilterMax) continue;
const announce = this.announces[entry.hash];
if (!announce || !aspectsToShow.includes(announce.aspect)) continue;

View File

@@ -98,7 +98,7 @@
<!-- search bar -->
<div
class="sticky top-0 z-10 py-3 sm:py-4 -mx-3 sm:mx-0 px-3 sm:px-0 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900 [box-shadow:0_1px_0_0_rgb(255_255_255_/_0.6)_inset] dark:[box-shadow:0_1px_0_0_rgb(39_39_42_/_0.5)_inset]"
class="sticky top-0 z-10 py-3 sm:py-4 -mx-3 sm:mx-0 px-3 sm:px-0 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-transparent"
>
<div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">