Refactor DropDownMenu and Conversation components for improved dropdown positioning and context menus

- Updated DropDownMenu to use Teleport for dropdown rendering, enhancing positioning and visibility.
- Refactored ConversationDropDownMenu and ConversationViewer to replace block-helper icon with gavel for consistency.
- Added import/export functionality for contacts in ContactsPage, including error handling and user feedback.
- Introduced context menus for favourites in NomadNetworkSidebar, allowing for banishment and renaming of nodes.
- Enhanced SettingsPage with new announce limits configuration inputs for better network management.
This commit is contained in:
Sudo-Ivan
2026-03-11 05:12:24 -05:00
parent 6589c2124a
commit debe65db1d
9 changed files with 562 additions and 167 deletions
@@ -8,24 +8,28 @@
<slot name="button" />
</div>
<!-- drop down menu -->
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isShowingMenu"
class="overflow-hidden absolute right-0 z-50 mr-4 w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
:class="[dropdownClass]"
@click.stop="hideMenu"
<Teleport to="body">
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<slot name="items" />
</div>
</Transition>
<div
v-if="isShowingMenu && dropdownPosition"
class="overflow-hidden fixed z-[200] w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
:style="{
left: dropdownPosition.x + 'px',
top: dropdownPosition.y + 'px',
}"
@click.stop="hideMenu"
>
<slot name="items" />
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -35,7 +39,7 @@ export default {
data() {
return {
isShowingMenu: false,
dropdownClass: null,
dropdownPosition: null,
};
},
methods: {
@@ -52,6 +56,7 @@ export default {
},
hideMenu() {
this.isShowingMenu = false;
this.dropdownPosition = null;
},
onClickOutsideMenu(event) {
if (this.isShowingMenu) {
@@ -61,39 +66,28 @@ export default {
},
adjustDropdownPosition() {
this.$nextTick(() => {
// find button and dropdown
const button = this.$refs["dropdown-button"];
if (!button) {
return;
}
if (!button) return;
const dropdown = button.parentElement?.querySelector(".absolute");
if (!dropdown) {
return;
}
// get bounding box of button
const buttonRect = button.getBoundingClientRect();
const estimatedHeight = 200;
const spaceBelow = window.innerHeight - buttonRect.bottom;
const spaceAbove = buttonRect.top;
// calculate how much space is under and above the button
const spaceBelowButton = window.innerHeight - buttonRect.bottom;
const spaceAboveButton = buttonRect.top;
let x = buttonRect.right - 224;
if (x < 8) x = 8;
if (x + 224 > window.innerWidth) x = window.innerWidth - 224 - 8;
// estimate dropdown height (will be measured after render)
const estimatedDropdownHeight = 150;
// calculate if there is enough space available to show dropdown
const hasEnoughSpaceAboveButton = spaceAboveButton > estimatedDropdownHeight;
const hasEnoughSpaceBelowButton = spaceBelowButton > estimatedDropdownHeight;
// show dropdown above button
if (hasEnoughSpaceAboveButton && !hasEnoughSpaceBelowButton) {
this.dropdownClass = "bottom-0 mb-12";
return;
let y;
if (spaceBelow >= estimatedHeight || spaceBelow >= spaceAbove) {
y = buttonRect.bottom + 4;
} else {
y = buttonRect.top - estimatedHeight - 4;
}
if (y < 8) y = 8;
if (y + estimatedHeight > window.innerHeight - 8) y = window.innerHeight - estimatedHeight - 8;
// otherwise fallback to showing dropdown below button
this.dropdownClass = "top-0 mt-12";
this.dropdownPosition = { x, y };
});
},
},
@@ -5,7 +5,7 @@
>
<div class="flex items-center gap-3">
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<MaterialDesignIcon icon-name="block-helper" class="size-6 text-red-600 dark:text-red-400" />
<MaterialDesignIcon icon-name="gavel" class="size-6 text-red-600 dark:text-red-400" />
</div>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{{ $t("banishment.title") }}</h1>
@@ -14,6 +14,19 @@
<MaterialDesignIcon icon-name="qrcode" class="size-4" />
{{ $t("contacts.share_my_identity") }}
</button>
<button
type="button"
class="secondary-chip"
:disabled="totalContactsCount === 0"
@click="exportContacts"
>
<MaterialDesignIcon icon-name="file-export" class="size-4" />
{{ $t("contacts.export_contacts") }}
</button>
<button type="button" class="secondary-chip" @click="openImportDialog">
<MaterialDesignIcon icon-name="file-import" class="size-4" />
{{ $t("contacts.import_contacts") }}
</button>
<button type="button" class="primary-chip" @click="openAddDialog">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("contacts.add_contact") }}
@@ -265,6 +278,49 @@
</div>
</div>
<!-- Import contacts dialog -->
<div
v-if="isImportDialogOpen"
class="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="closeImportDialog"
>
<div class="w-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-zinc-100">
{{ $t("contacts.import_modal_title") }}
</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="closeImportDialog"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-gray-600 dark:text-zinc-400">
{{ $t("contacts.import_file_hint") }}
</p>
<input
ref="importFileInput"
type="file"
accept=".json,application/json"
class="hidden"
@change="onImportFileChange"
/>
<button
type="button"
class="secondary-chip w-full justify-center"
@click="$refs.importFileInput?.click()"
>
<MaterialDesignIcon icon-name="file-upload" class="size-4" />
{{ $t("contacts.import_contacts") }}
</button>
<p v-if="importError" class="text-sm text-red-600 dark:text-red-400">{{ importError }}</p>
</div>
</div>
</div>
<!-- My identity dialog -->
<div
v-if="isMyIdentityDialogOpen"
@@ -363,6 +419,9 @@ export default {
y: 0,
contact: null,
},
isImportDialogOpen: false,
importError: null,
};
},
computed: {
@@ -458,6 +517,68 @@ export default {
this.pendingLxmaImport = false;
this.isAddDialogOpen = true;
},
openImportDialog() {
this.importError = null;
this.isImportDialogOpen = true;
},
closeImportDialog() {
this.isImportDialogOpen = false;
this.importError = null;
},
onImportFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
this.importError = null;
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target?.result || "{}");
const contacts = json.contacts ?? (Array.isArray(json) ? json : []);
if (!Array.isArray(contacts) || contacts.length === 0) {
this.importError = this.$t("contacts.import_failed");
return;
}
this.importContacts(contacts);
} catch {
this.importError = this.$t("contacts.import_failed");
}
};
reader.readAsText(file);
event.target.value = "";
},
async importContacts(contacts) {
try {
const response = await window.axios.post("/api/v1/telephone/contacts/import", {
contacts,
});
const added = response.data?.added ?? 0;
ToastUtils.success(this.$t("contacts.import_success", { added }));
this.closeImportDialog();
await this.getContacts();
} catch (e) {
this.importError = e?.response?.data?.message || this.$t("contacts.import_failed");
}
},
async exportContacts() {
try {
const response = await window.axios.get("/api/v1/telephone/contacts/export");
const contacts = response.data?.contacts ?? [];
const blob = new Blob([JSON.stringify({ contacts }, null, 2)], {
type: "application/json",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "contacts_export.json");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
ToastUtils.success(this.$t("contacts.export_success"));
} catch (e) {
ToastUtils.error(e?.response?.data?.message || this.$t("contacts.export_failed"));
}
},
closeAddDialog() {
this.isAddDialogOpen = false;
this.pendingLxmaImport = false;
@@ -27,7 +27,7 @@
<!-- block/unblock button -->
<div class="border-t">
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
<MaterialDesignIcon icon-name="block-helper" class="size-5 text-red-500" />
<MaterialDesignIcon icon-name="gavel" class="size-5 text-red-500" />
<span class="text-red-500">Banish User</span>
</DropDownMenuItem>
<DropDownMenuItem v-else @click="onUnblockDestination">
@@ -149,6 +149,16 @@
<MaterialDesignIcon icon-name="notebook-outline" class="size-7" />
</IconButton>
<!-- banish button (visible when peer not blocked) -->
<IconButton
v-if="!isSelectedPeerBlocked"
:title="$t('messages.banish_user')"
class="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="onBanishHeaderClick"
>
<MaterialDesignIcon icon-name="gavel" class="size-7" />
</IconButton>
<ConversationDropDownMenu
v-if="selectedPeer"
:peer="selectedPeer"
@@ -438,6 +448,15 @@
:style="bubbleStyles(chatItem)"
@click="onChatItemClick(chatItem)"
>
<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'"
:title="$t('messages.message_actions')"
@click.stop="onMessageContextMenu($event, chatItem)"
>
<MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
</button>
<div class="w-full space-y-1 px-4 py-2.5 min-w-0">
<!-- reply snippet -->
<div
@@ -1197,13 +1216,19 @@
<!-- hidden file input for selecting files -->
<input ref="file-input" type="file" multiple style="display: none" @change="onFileInputChange" />
<!-- Message Context Menu -->
<div
v-if="messageContextMenu.show"
v-click-outside="{ handler: () => (messageContextMenu.show = false), capture: true }"
class="fixed z-[100] min-w-[180px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: messageContextMenu.y + 'px', left: messageContextMenu.x + 'px' }"
>
<!-- Message Context Menu (Teleport to body to avoid overflow clipping) -->
<Teleport to="body">
<div
v-if="messageContextMenu.show"
v-click-outside="{
handler: () => {
if (!messageContextMenu.justOpened) messageContextMenu.show = false;
},
capture: true,
}"
class="fixed z-[200] min-w-[180px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: messageContextMenu.y + 'px', left: messageContextMenu.x + 'px' }"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@@ -1250,7 +1275,8 @@
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
<span class="font-medium">Delete</span>
</button>
</div>
</div>
</Teleport>
</div>
<!-- no peer selected -->
@@ -1833,6 +1859,7 @@ export default {
x: 0,
y: 0,
chatItem: null,
justOpened: false,
},
now: Date.now(),
updateTimer: null,
@@ -2872,12 +2899,30 @@ export default {
}
},
async onConversationDeleted() {
// reload conversation
await this.initialLoad();
// reload conversations
this.$emit("reload-conversations");
},
async onBanishHeaderClick() {
if (!this.selectedPeer) return;
if (
!(await DialogUtils.confirm(
this.$t("messages.banish_confirm") ||
"Are you sure you want to banish this user? They will not be able to send you messages or establish links."
))
) {
return;
}
try {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: this.selectedPeer.destination_hash,
});
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert(this.$t("messages.user_banished"));
} catch (e) {
DialogUtils.alert(this.$t("messages.failed_banish_user"));
console.error(e);
}
},
onChatItemClick: function (chatItem) {
if (!chatItem.is_actions_expanded) {
chatItem.is_actions_expanded = true;
@@ -2922,28 +2967,28 @@ export default {
},
onMessageContextMenu(event, chatItem) {
this.messageContextMenu.chatItem = chatItem;
this.messageContextMenu.justOpened = true;
this.messageContextMenu.show = true;
// wait for context menu to be rendered to calculate its width/height
this.$nextTick(() => {
const menuWidth = 180; // approximate minimum width
const menuHeight = 150; // approximate height
const menuWidth = 180;
const menuHeight = 150;
let x = event.clientX;
let y = event.clientY;
// Adjust X if it would go off-screen on the right
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 10;
}
// Adjust Y if it would go off-screen at the bottom
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 10;
}
this.messageContextMenu.x = x;
this.messageContextMenu.y = y;
setTimeout(() => {
this.messageContextMenu.justOpened = false;
}, 50);
});
},
async showRawMessage(chatItem) {
@@ -425,39 +425,42 @@ export default {
if (window.axios.isCancel(e)) return;
}
},
async getPathTableBatch() {
async getPathTableBatch(destinationHashes = null) {
this.pathTable = [];
try {
this.loadingStatus = "Loading Paths...";
const firstResp = await window.axios.get(`/api/v1/path-table`, {
params: { limit: this.pageSize, offset: 0 },
signal: this.abortController.signal,
});
this.pathTable.push(...firstResp.data.path_table);
const totalCount = firstResp.data.total_count;
if (totalCount > this.pageSize) {
const remainingOffsets = [];
for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize) {
remainingOffsets.push(offset);
}
// Fetch remaining batches in parallel with limited concurrency to not overwhelm backend
const concurrency = 3;
for (let i = 0; i < remainingOffsets.length; i += concurrency) {
if (this.abortController.signal.aborted) return;
const chunk = remainingOffsets.slice(i, i + concurrency);
const promises = chunk.map((offset) =>
window.axios.get(`/api/v1/path-table`, {
params: { limit: this.pageSize, offset: offset },
signal: this.abortController.signal,
})
);
const responses = await Promise.all(promises);
for (const r of responses) {
this.pathTable.push(...r.data.path_table);
this.loadingStatus = "Loading paths...";
if (destinationHashes && destinationHashes.length > 0) {
const resp = await window.axios.post(`/api/v1/path-table`, { destination_hashes: destinationHashes }, {
signal: this.abortController.signal,
});
this.pathTable.push(...resp.data.path_table);
} else {
const firstResp = await window.axios.get(`/api/v1/path-table`, {
params: { limit: this.pageSize, offset: 0 },
signal: this.abortController.signal,
});
this.pathTable.push(...firstResp.data.path_table);
const totalCount = firstResp.data.total_count;
if (totalCount > this.pageSize) {
const concurrency = 3;
for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize * concurrency) {
if (this.abortController.signal.aborted) return;
const chunk = [];
for (let i = 0; i < concurrency && offset + i * this.pageSize < totalCount; i++) {
chunk.push(offset + i * this.pageSize);
}
const promises = chunk.map((o) =>
window.axios.get(`/api/v1/path-table`, {
params: { limit: this.pageSize, offset: o },
signal: this.abortController.signal,
})
);
const responses = await Promise.all(promises);
for (const r of responses) {
this.pathTable.push(...r.data.path_table);
}
this.loadingStatus = `Loading paths (${this.pathTable.length} / ${totalCount})`;
}
this.loadingStatus = `Loading Paths (${this.pathTable.length} / ${totalCount})`;
}
}
} catch (e) {
@@ -467,41 +470,26 @@ export default {
},
async getAnnouncesBatch() {
this.announces = {};
const aspectsToFetch = ["lxmf.delivery", "nomadnetwork.node"];
try {
this.loadingStatus = "Loading Announces...";
const firstResp = await window.axios.get(`/api/v1/announces`, {
params: { limit: this.pageSize, offset: 0 },
signal: this.abortController.signal,
});
for (const announce of firstResp.data.announces) {
this.announces[announce.destination_hash] = announce;
}
const totalCount = firstResp.data.total_count;
if (totalCount > this.pageSize) {
const remainingOffsets = [];
for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize) {
remainingOffsets.push(offset);
}
const concurrency = 3;
for (let i = 0; i < remainingOffsets.length; i += concurrency) {
if (this.abortController.signal.aborted) return;
const chunk = remainingOffsets.slice(i, i + concurrency);
const promises = chunk.map((offset) =>
window.axios.get(`/api/v1/announces`, {
params: { limit: this.pageSize, offset: offset },
signal: this.abortController.signal,
})
);
const responses = await Promise.all(promises);
for (const r of responses) {
for (const announce of r.data.announces) {
this.announces[announce.destination_hash] = announce;
}
for (const aspect of aspectsToFetch) {
if (this.abortController.signal.aborted) return;
this.loadingStatus = `Loading ${aspect}...`;
let offset = 0;
let hasMore = true;
while (hasMore) {
const resp = await window.axios.get(`/api/v1/announces`, {
params: { aspect, limit: this.pageSize, offset },
signal: this.abortController.signal,
});
for (const announce of resp.data.announces) {
this.announces[announce.destination_hash] = announce;
}
this.loadingStatus = `Loading Announces (${Object.keys(this.announces).length} / ${totalCount})`;
const loaded = Object.keys(this.announces).length;
const total = resp.data.total_count;
this.loadingStatus = `Loading announces (${loaded})`;
offset += resp.data.announces.length;
hasMore = resp.data.announces.length === this.pageSize && offset < total;
}
}
} catch (e) {
@@ -1100,7 +1088,9 @@ export default {
if (this.abortController.signal.aborted) return;
this.loadingStatus = "Fetching network data...";
await Promise.all([this.getPathTableBatch(), this.getAnnouncesBatch()]);
await this.getAnnouncesBatch();
if (this.abortController.signal.aborted) return;
await this.getPathTableBatch(Object.keys(this.announces));
if (this.abortController.signal.aborted) return;
await this.processVisualization();
@@ -14,6 +14,7 @@
@node-click="onNodeClick"
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"
@add-favourite="addFavourite"
@nodes-search-changed="onNodesSearchChanged"
@load-more-nodes="loadMoreNodes"
/>
@@ -161,7 +161,7 @@
</div>
</div>
<IconButton
class="text-gray-500 dark:text-gray-300"
class="flex-shrink-0 text-gray-500 dark:text-gray-300"
@click.stop="openFavouriteContextMenu($event, favourite, section.id)"
>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
@@ -183,29 +183,53 @@
</div>
</div>
<!-- Favourite Context Menu -->
<div
v-if="favouriteContextMenu.show"
v-click-outside="{ handler: closeContextMenus, capture: true }"
class="fixed z-[100] min-w-[220px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: favouriteContextMenu.y + 'px', left: favouriteContextMenu.x + 'px' }"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="renameFavouriteFromContext"
<!-- Favourite Context Menu (Teleport to body to avoid overflow clipping) -->
<Teleport to="body">
<div
v-if="favouriteContextMenu.show"
v-click-outside="{
handler: () => {
if (!favouriteContextMenu.justOpened) closeContextMenus();
},
capture: true,
}"
class="fixed z-[200] min-w-[220px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: favouriteContextMenu.y + 'px', left: favouriteContextMenu.x + 'px' }"
>
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
<span class="font-medium">{{ $t("nomadnet.rename") }}</span>
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
@click="removeFavouriteFromContext"
>
<MaterialDesignIcon icon-name="trash-can" class="size-4 text-red-400" />
<span class="font-medium">{{ $t("nomadnet.remove") }}</span>
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="renameFavouriteFromContext"
>
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
<span class="font-medium">{{ $t("nomadnet.rename") }}</span>
</button>
<button
v-if="!isBlocked(favouriteContextMenu.targetHash)"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
@click="banishFavouriteFromContext"
>
<MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
<span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
</button>
<button
v-else
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
@click="unblockFavouriteFromContext"
>
<MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
<span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
@click="removeFavouriteFromContext"
>
<MaterialDesignIcon icon-name="trash-can" class="size-4 text-red-400" />
<span class="font-medium">{{ $t("nomadnet.remove") }}</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<div
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
@@ -224,15 +248,17 @@
<span class="truncate">{{ section.name }}</span>
</button>
</div>
</div>
</div>
</Teleport>
<!-- Section Context Menu -->
<div
v-if="sectionContextMenu.show"
v-click-outside="{ handler: closeContextMenus, capture: true }"
class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: sectionContextMenu.y + 'px', left: sectionContextMenu.x + 'px' }"
>
<!-- Section Context Menu (Teleport to body) -->
<Teleport to="body">
<div
v-if="sectionContextMenu.show"
v-click-outside="{ handler: closeContextMenus, capture: true }"
class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: sectionContextMenu.y + 'px', left: sectionContextMenu.x + 'px' }"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@@ -251,7 +277,8 @@
<MaterialDesignIcon icon-name="delete" class="size-4 text-red-400" />
<span class="font-medium">Delete Section</span>
</button>
</div>
</div>
</Teleport>
</div>
<div v-else class="flex-1 flex flex-col min-h-0">
@@ -271,6 +298,7 @@
:key="node.destination_hash"
class="announce-card relative"
:class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }"
@contextmenu.prevent="openAnnounceContextMenu($event, node)"
>
<!-- banished overlay -->
<div
@@ -310,7 +338,8 @@
</div>
</div>
</div>
<DropDownMenu>
<div class="flex-shrink-0">
<DropDownMenu>
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
@@ -318,15 +347,16 @@
</template>
<template #items>
<DropDownMenuItem v-if="!isBlocked(node.identity_hash)" @click.stop="onBlockNode(node)">
<MaterialDesignIcon icon-name="block-helper" class="w-5 h-5 text-red-500" />
<MaterialDesignIcon icon-name="gavel" class="w-5 h-5 text-red-500" />
<span class="text-red-500">{{ $t("nomadnet.block_node") }}</span>
</DropDownMenuItem>
<DropDownMenuItem v-else @click.stop="onUnblockNode(node.identity_hash)">
<MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
<span class="text-green-500">Lift Banishment</span>
<span class="text-green-500">{{ $t("nomadnet.lift_banishment") }}</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
</div>
</div>
<!-- loading more spinner -->
@@ -340,6 +370,49 @@
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $t("nomadnet.listening_for_peers") }}</div>
</div>
</div>
<!-- Announce Context Menu (right-click, Teleport to body) -->
<Teleport to="body">
<div
v-if="announceContextMenu.show"
v-click-outside="{
handler: () => {
if (!announceContextMenu.justOpened) closeContextMenus();
},
capture: true,
}"
class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: announceContextMenu.y + 'px', left: announceContextMenu.x + 'px' }"
>
<button
v-if="!isFavourite(announceContextMenu.node?.destination_hash)"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="addFavouriteFromContext"
>
<MaterialDesignIcon icon-name="star-outline" class="size-4 text-yellow-500" />
<span class="font-medium">{{ $t("nomadnet.add_favourite") }}</span>
</button>
<button
v-if="!isBlocked(announceContextMenu.node?.identity_hash)"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
@click="blockAnnounceFromContext"
>
<MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
<span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
</button>
<button
v-else
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
@click="unblockAnnounceFromContext"
>
<MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
<span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
</button>
</div>
</Teleport>
</div>
</div>
</template>
@@ -388,7 +461,14 @@ export default {
default: false,
},
},
emits: ["node-click", "rename-favourite", "remove-favourite", "nodes-search-changed", "load-more-nodes"],
emits: [
"node-click",
"rename-favourite",
"remove-favourite",
"add-favourite",
"nodes-search-changed",
"load-more-nodes",
],
data() {
return {
GlobalState,
@@ -409,6 +489,7 @@ export default {
y: 0,
targetHash: null,
targetSectionId: null,
justOpened: false,
},
sectionContextMenu: {
show: false,
@@ -416,6 +497,13 @@ export default {
y: 0,
sectionId: null,
},
announceContextMenu: {
show: false,
x: 0,
y: 0,
node: null,
justOpened: false,
},
};
},
computed: {
@@ -594,6 +682,36 @@ export default {
isBlocked(identityHash) {
return this.blockedDestinations.some((b) => b.destination_hash === identityHash);
},
isFavourite(destinationHash) {
return this.favourites.some((f) => f.destination_hash === destinationHash);
},
addFavouriteFromContext() {
const node = this.announceContextMenu.node;
if (!node) {
this.closeContextMenus();
return;
}
this.closeContextMenus();
this.$emit("add-favourite", node);
},
async blockAnnounceFromContext() {
const node = this.announceContextMenu.node;
if (!node) {
this.closeContextMenus();
return;
}
this.closeContextMenus();
await this.onBlockNode(node);
},
async unblockAnnounceFromContext() {
const node = this.announceContextMenu.node;
if (!node) {
this.closeContextMenus();
return;
}
this.closeContextMenus();
await this.onUnblockNode(node.identity_hash);
},
async onBlockNode(node) {
if (!(await DialogUtils.confirm(this.$t("nomadnet.block_node_confirm", { name: node.display_name })))) {
return;
@@ -742,12 +860,16 @@ export default {
openFavouriteContextMenu(event, favourite, sectionId) {
this.favouriteContextMenu = {
show: true,
x: event.pageX || event.clientX,
y: event.pageY || event.clientY,
justOpened: true,
x: event.clientX,
y: event.clientY,
targetHash: favourite.destination_hash,
targetSectionId: sectionId,
};
this.sectionContextMenu.show = false;
setTimeout(() => {
this.favouriteContextMenu.justOpened = false;
}, 50);
},
openSectionContextMenu(event, section) {
this.sectionContextMenu = {
@@ -761,6 +883,21 @@ export default {
closeContextMenus() {
this.favouriteContextMenu.show = false;
this.sectionContextMenu.show = false;
this.announceContextMenu.show = false;
},
openAnnounceContextMenu(event, node) {
this.announceContextMenu = {
show: true,
justOpened: true,
x: event.clientX,
y: event.clientY,
node,
};
this.favouriteContextMenu.show = false;
this.sectionContextMenu.show = false;
setTimeout(() => {
this.announceContextMenu.justOpened = false;
}, 50);
},
getFavouriteByHash(hash) {
return this.favourites.find((fav) => fav.destination_hash === hash);
@@ -783,6 +920,47 @@ export default {
this.closeContextMenus();
this.onRemoveFavourite(favourite);
},
async banishFavouriteFromContext() {
const favourite = this.getFavouriteByHash(this.favouriteContextMenu.targetHash);
if (!favourite) {
this.closeContextMenus();
return;
}
this.closeContextMenus();
if (
!(await DialogUtils.confirm(
this.$t("nomadnet.block_node_confirm", { name: favourite.display_name || favourite.custom_display_name })
))
) {
return;
}
try {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: favourite.destination_hash,
});
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert(this.$t("nomadnet.node_blocked_successfully"));
} catch (e) {
DialogUtils.alert(this.$t("nomadnet.failed_to_block_node"));
console.error(e);
}
},
async unblockFavouriteFromContext() {
const hash = this.favouriteContextMenu.targetHash;
if (!hash) {
this.closeContextMenus();
return;
}
this.closeContextMenus();
try {
await window.axios.delete(`/api/v1/blocked-destinations/${hash}`);
GlobalEmitter.emit("block-status-changed");
DialogUtils.alert(this.$t("nomadnet.banishment_lifted"));
} catch (e) {
DialogUtils.alert(this.$t("nomadnet.failed_lift_banishment"));
console.error(e);
}
},
moveContextFavouriteToSection(sectionId) {
if (!this.favouriteContextMenu.targetHash) {
return;
@@ -1039,11 +1039,12 @@
<!-- Network Security -->
<section
v-show="
matchesSearch(
matchesSearch(
'RNS Security',
'Network Security',
'app.blackhole_integration_enabled',
'app.blackhole_integration_description'
'app.blackhole_integration_description',
'app.announce_limits'
)
"
class="glass-card break-inside-avoid"
@@ -1077,6 +1078,64 @@
"
/>
</div>
<div class="space-y-3">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.announce_limits") }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.announce_limits_description") }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_limit_lxmf") }}</label>
<input
v-model.number="config.announce_limit_lxmf_delivery"
type="number"
min="0"
class="input-field"
placeholder="-"
@change="
updateConfig(
{ announce_limit_lxmf_delivery: config.announce_limit_lxmf_delivery ?? null },
'announce_limits'
)
"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_limit_nomadnet") }}</label>
<input
v-model.number="config.announce_limit_nomadnetwork_node"
type="number"
min="0"
class="input-field"
placeholder="-"
@change="
updateConfig(
{ announce_limit_nomadnetwork_node: config.announce_limit_nomadnetwork_node ?? null },
'announce_limits'
)
"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium">{{ $t("app.announce_limit_prop") }}</label>
<input
v-model.number="config.announce_limit_lxmf_propagation"
type="number"
min="0"
class="input-field"
placeholder="-"
@change="
updateConfig(
{ announce_limit_lxmf_propagation: config.announce_limit_lxmf_propagation ?? null },
'announce_limits'
)
"
/>
</div>
</div>
</div>
</div>
</section>
@@ -1679,6 +1738,9 @@ export default {
banished_text: "BANISHED",
banished_color: "#dc2626",
blackhole_integration_enabled: true,
announce_limit_lxmf_delivery: null,
announce_limit_nomadnetwork_node: null,
announce_limit_lxmf_propagation: null,
message_font_size: 14,
message_icon_size: 28,
message_outbound_bubble_color: "#4f46e5",
@@ -1776,6 +1838,10 @@ export default {
"Network Security",
"app.blackhole_integration_enabled",
"app.blackhole_integration_description",
"app.announce_limits",
"app.announce_limit_lxmf",
"app.announce_limit_nomadnet",
"app.announce_limit_prop",
],
transport: [
"Reticulum",