Files
Mycell-UI/src/views/Topics.vue
T
syoul f28d0e1338 P4: messages, topics, pubkey
Backend
- api/messages.rs covers send/pop/reply/status with an externally
  tagged MessageDestination enum that matches the daemon's
  {ip|pk: ...} body shape; pop_message uses an inflated request
  timeout to outlast the long-poll window
- api/topics.rs implements default action, topic CRUD, sources
  whitelist, and forward-socket get/set/remove. POST /topics ships
  the raw base64 string as the body (not JSON); path segments are
  percent-encoded inline (topics contain '/' and '+')
- api/pubkey.rs resolves an overlay IPv6 to a hex public key
- poller spawns a third long-poll loop on /messages?peek=false
  that fans every inbound message into a 200-deep ring buffer and
  emits messages://incoming for the UI

Frontend
- messages store: live inbox via the event, persisted outbox via
  tauri-plugin-store keyed under outbox.json
- ComposeMessage form: ip/pk toggle, optional UTF-8 topic and
  payload that get base64-encoded with a TextEncoder-based helper
- MessageList renders printable payloads decoded; binary payloads
  fall back to a "(N bytes binary)" hint
- Topics view: split layout with whitelist on the left, per-topic
  sources/forward editor on the right; default-action toggle is
  surfaced at the top
2026-04-25 23:10:21 +02:00

263 lines
9.0 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { Plus, Trash2, Hash } from "lucide-vue-next";
import { useTopicsStore } from "@/stores/topics";
import { useNodeStore } from "@/stores/node";
import { base64ToUtf8, isPrintableUtf8, utf8ToBase64 } from "@/lib/utils";
const topicsStore = useTopicsStore();
const node = useNodeStore();
const { topics, defaultAction, sources, forwards, error } = storeToRefs(topicsStore);
const { phase } = storeToRefs(node);
const isReady = computed(() => phase.value === "ready");
const newTopic = ref("");
const selected = ref<string | null>(null);
const newSource = ref("");
const newForward = ref("");
onMounted(async () => {
if (isReady.value) await topicsStore.refresh();
});
watch(
() => topics.value,
(list) => {
if (selected.value && !list.includes(selected.value)) selected.value = null;
if (!selected.value && list.length) selected.value = list[0];
},
{ immediate: true },
);
watch(selected, async (t) => {
if (!t) return;
await Promise.all([topicsStore.refreshSources(t), topicsStore.refreshForward(t)]);
});
function topicLabel(t: string): string {
return isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t.slice(0, 18)}`;
}
async function addTopic() {
const v = newTopic.value.trim();
if (!v) return;
try {
await topicsStore.addTopic(utf8ToBase64(v));
newTopic.value = "";
} catch (e) {
alert(`Could not add topic: ${e}`);
}
}
async function removeTopic(t: string) {
if (!confirm(`Remove topic "${topicLabel(t)}"?`)) return;
try {
await topicsStore.removeTopic(t);
} catch (e) {
alert(`Could not remove: ${e}`);
}
}
async function addSource() {
if (!selected.value || !newSource.value.trim()) return;
try {
await topicsStore.addSource(selected.value, newSource.value.trim());
newSource.value = "";
} catch (e) {
alert(`Could not add source: ${e}`);
}
}
async function removeSource(subnet: string) {
if (!selected.value) return;
try {
await topicsStore.removeSource(selected.value, subnet);
} catch (e) {
alert(`Could not remove source: ${e}`);
}
}
async function setForward() {
if (!selected.value || !newForward.value.trim()) return;
try {
await topicsStore.setForward(selected.value, newForward.value.trim());
newForward.value = "";
} catch (e) {
alert(`Could not set forward: ${e}`);
}
}
async function clearForward() {
if (!selected.value) return;
if (!confirm("Remove forward socket?")) return;
await topicsStore.removeForward(selected.value);
}
async function toggleDefault() {
await topicsStore.setDefault(!defaultAction.value.accept);
}
</script>
<template>
<div v-if="!isReady" class="text-sm text-muted-foreground">Daemon offline.</div>
<div v-else class="grid gap-4 lg:grid-cols-[260px_1fr]">
<section class="space-y-3">
<div class="rounded-lg border border-border bg-card p-3">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Default action
</div>
<button
class="mt-2 w-full rounded-md border border-border px-3 py-2 text-sm hover:bg-secondary"
@click="toggleDefault"
>
{{ defaultAction.accept ? "Accept by default" : "Reject by default" }}
</button>
<p class="mt-2 text-[11px] text-muted-foreground">
Controls how the daemon treats topics that aren't in the whitelist
below.
</p>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="text-sm font-medium">Whitelisted topics</span>
<span class="text-xs text-muted-foreground">{{ topics.length }}</span>
</header>
<ul class="max-h-[40vh] divide-y divide-border overflow-y-auto">
<li v-if="!topics.length" class="px-3 py-4 text-center text-xs text-muted-foreground">
No topics yet.
</li>
<li
v-for="t in topics"
:key="t"
class="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/30"
:class="selected === t ? 'bg-secondary text-secondary-foreground' : ''"
@click="selected = t"
>
<Hash class="h-3 w-3 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">
{{ topicLabel(t) }}
</span>
<button
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click.stop="removeTopic(t)"
>
<Trash2 class="h-3 w-3" />
</button>
</li>
</ul>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newTopic"
type="text"
placeholder="topic name"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="addTopic"
/>
<button
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="addTopic"
>
<Plus class="h-3 w-3" /> Add
</button>
</footer>
</div>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</section>
<section v-if="selected" class="space-y-4">
<div class="rounded-lg border border-border bg-card p-4">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Selected topic
</div>
<div class="mt-1 break-all font-mono text-sm">
{{ topicLabel(selected) }}
</div>
<div class="mt-1 break-all text-[11px] text-muted-foreground">
base64: {{ selected }}
</div>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-2 text-sm font-medium">
Allowed source subnets
</header>
<ul class="divide-y divide-border">
<li
v-if="!(sources[selected]?.length)"
class="px-4 py-3 text-center text-xs text-muted-foreground"
>
No sources — all subnets allowed for this topic.
</li>
<li
v-for="s in sources[selected] ?? []"
:key="s"
class="flex items-center justify-between px-4 py-2 text-sm hover:bg-muted/30"
>
<span class="font-mono text-xs">{{ s }}</span>
<button
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click="removeSource(s)"
>
<Trash2 class="h-3 w-3" />
</button>
</li>
</ul>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newSource"
type="text"
placeholder="503:5478:df06:d79a::/64"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="addSource"
/>
<button
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="addSource"
>
<Plus class="h-3 w-3" /> Allow
</button>
</footer>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-2 text-sm font-medium">
Forward socket
</header>
<div class="px-4 py-3 text-sm">
<span v-if="forwards[selected]" class="font-mono text-xs">
{{ forwards[selected] }}
</span>
<span v-else class="text-muted-foreground">No forward configured.</span>
</div>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newForward"
type="text"
placeholder="/var/run/myapp.sock"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="setForward"
/>
<button
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="setForward"
>
Set
</button>
<button
v-if="forwards[selected]"
class="rounded-md border border-border px-2 py-1 text-xs hover:bg-destructive/10 hover:text-destructive"
@click="clearForward"
>
Clear
</button>
</footer>
</div>
</section>
<section v-else class="text-sm text-muted-foreground">
Pick a topic on the left to manage its sources and forward.
</section>
</div>
</template>