mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-07-11 00:11:58 +00:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user