Add Nostr chat drawer

This commit is contained in:
Dorian
2026-05-06 19:52:20 +01:00
parent b2f81a1f8d
commit b715c3f27d
5 changed files with 458 additions and 1 deletions

View File

@@ -25,7 +25,7 @@ export function buildApp() {
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"img-src": ["'self'", "data:", "https:"],
"connect-src": ["'self'", "wss://relay.primal.net"],
"font-src": ["'self'", "data:"],
"manifest-src": ["'self'"],

View File

@@ -3,11 +3,13 @@ import { computed, onMounted, ref, watch } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useStatsStore } from "./stores/stats";
import ChatDrawer from "./components/ChatDrawer.vue";
const auth = useAuthStore();
const stats = useStatsStore();
const route = useRoute();
const crt = ref(false);
const chatOpen = ref(false);
const LOW_HASHRATE_THS = 10;
const TOP_HASHRATE_THS = 70;
const heatLevel = computed(() => {
@@ -55,6 +57,9 @@ watch(
<div class="actions">
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
<template v-if="auth.isLoggedIn">
<button class="thin icon-btn" aria-label="open chat" @click="chatOpen = !chatOpen">
<span class="chat-icon" />
</button>
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
<button class="thin" @click="auth.logout()">logout</button>
</template>
@@ -68,6 +73,8 @@ watch(
<footer class="footbar muted">
P(block) is a lifestyle, not a number · gashboard v0.1 · {{ new Date().getFullYear() }}
</footer>
<ChatDrawer v-if="auth.isLoggedIn" :open="chatOpen" @close="chatOpen = false" />
</template>
<style scoped>
@@ -124,6 +131,30 @@ button.thin {
padding: 4px 10px;
font-size: 11px;
}
.icon-btn {
width: 30px;
height: 24px;
display: grid;
place-items: center;
padding: 0;
}
.chat-icon {
width: 14px;
height: 10px;
border: 1px solid currentColor;
position: relative;
}
.chat-icon::after {
content: "";
position: absolute;
right: 2px;
bottom: -5px;
width: 6px;
height: 5px;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: skewY(-35deg);
}
main {
padding: 24px;
max-width: 1400px;

View File

@@ -0,0 +1,306 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useAuthStore } from "../stores/auth";
import * as chat from "../services/chat";
import type { ChatMessage, ChatProfile } from "../services/chat";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
const auth = useAuthStore();
const messages = ref<ChatMessage[]>([]);
const profiles = ref<Record<string, ChatProfile>>({});
const draft = ref("");
const error = ref("");
const loading = ref(false);
const listEl = ref<HTMLElement | null>(null);
let sub: { close: () => void } | null = null;
const ownPubkey = computed(() => chat.pubkeyFromNpub(auth.npub));
const canSend = computed(() => chat.canSendChat());
onMounted(() => {
if (auth.isLoggedIn) void start();
});
onUnmounted(() => {
sub?.close();
});
watch(
() => auth.isLoggedIn,
(loggedIn) => {
if (loggedIn) void start();
else {
sub?.close();
sub = null;
messages.value = [];
profiles.value = {};
}
},
);
watch(
() => props.open,
(open) => {
if (open) void scrollBottom();
},
);
async function start(): Promise<void> {
if (sub) return;
loading.value = true;
error.value = "";
try {
messages.value = await chat.loadChat();
await loadMissingProfiles();
sub = chat.subscribeChat((message) => {
if (!messages.value.some((m) => m.id === message.id)) {
messages.value.push(message);
messages.value.sort((a, b) => a.createdAt - b.createdAt);
void loadMissingProfiles();
void scrollBottom();
}
});
await scrollBottom();
} catch (e) {
error.value = e instanceof Error ? e.message : "Chat failed";
} finally {
loading.value = false;
}
}
async function loadMissingProfiles(): Promise<void> {
const authors = messages.value.map((m) => m.pubkey);
if (ownPubkey.value) authors.push(ownPubkey.value);
const missing = [...new Set(authors)].filter((p) => !profiles.value[p]);
if (!missing.length) return;
profiles.value = { ...profiles.value, ...(await chat.loadProfiles(missing)) };
}
async function send(): Promise<void> {
const content = draft.value.trim();
if (!content) return;
error.value = "";
try {
const message = await chat.sendChat(content);
if (!messages.value.some((m) => m.id === message.id)) messages.value.push(message);
draft.value = "";
await loadMissingProfiles();
await scrollBottom();
} catch (e) {
error.value = e instanceof Error ? e.message : "Could not send";
}
}
function profile(pubkey: string): ChatProfile {
return profiles.value[pubkey] ?? { pubkey, name: chat.shortPubkey(pubkey), picture: "" };
}
function timeLabel(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
async function scrollBottom(): Promise<void> {
await nextTick();
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight;
}
</script>
<template>
<div v-if="open" class="chat-backdrop" @click="emit('close')" />
<aside :class="['chat-drawer', { open }]">
<header>
<div>
<div class="label">nostr chat</div>
<h2 class="glow-cyan">mining desk heckle box</h2>
</div>
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>
</header>
<div v-if="!canSend" class="notice">
Logged in, but the signer is not active in this tab. Reading works; sending needs a fresh signer login.
</div>
<div v-if="error" class="notice err">{{ error }}</div>
<div ref="listEl" class="messages">
<div v-if="loading" class="empty muted">loading relay gossip...</div>
<div v-else-if="!messages.length" class="empty muted">no chat yet. terrifying discipline.</div>
<article
v-for="message in messages"
:key="message.id"
:class="['msg', { own: message.pubkey === ownPubkey }]"
>
<img v-if="profile(message.pubkey).picture" :src="profile(message.pubkey).picture" alt="" />
<div v-else class="avatar">{{ profile(message.pubkey).name.slice(0, 2).toUpperCase() }}</div>
<div class="bubble">
<div class="meta">
<strong>{{ profile(message.pubkey).name }}</strong>
<span>{{ timeLabel(message.createdAt) }}</span>
</div>
<p>{{ message.content }}</p>
</div>
</article>
</div>
<form class="composer" @submit.prevent="send">
<textarea
v-model="draft"
:disabled="!canSend"
rows="2"
maxlength="500"
placeholder="type something irresponsible but signed..."
/>
<button class="primary" :disabled="!canSend || !draft.trim()">send</button>
</form>
</aside>
</template>
<style scoped>
.chat-backdrop {
position: fixed;
inset: 0;
z-index: 18;
background: rgba(0, 0, 0, 0.2);
}
.chat-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 20;
width: min(430px, 92vw);
height: 100vh;
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 12px;
padding: 16px;
background: rgba(7, 9, 15, 0.97);
border-left: 1px solid var(--line-bright);
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.45);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.chat-drawer.open {
transform: translateX(0);
}
header {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
h2 {
margin: 2px 0 0;
font-size: 18px;
letter-spacing: 0;
}
.notice {
border: 1px solid var(--line);
color: var(--fg-1);
padding: 9px 10px;
font-size: 11px;
line-height: 1.35;
}
.notice.err {
border-color: var(--neon-red);
color: var(--neon-red);
}
.messages {
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
}
.empty {
padding: 24px 0;
text-align: center;
font-size: 12px;
}
.msg {
display: grid;
grid-template-columns: 34px 1fr;
gap: 8px;
align-items: start;
}
.msg.own {
grid-template-columns: 1fr 34px;
}
.msg.own img,
.msg.own .avatar {
grid-column: 2;
grid-row: 1;
}
.msg.own .bubble {
grid-column: 1;
grid-row: 1;
}
img,
.avatar {
width: 34px;
height: 34px;
border: 1px solid var(--line-bright);
object-fit: cover;
}
.avatar {
display: grid;
place-items: center;
color: var(--neon-cyan);
background: var(--bg-2);
font-size: 10px;
font-weight: 700;
}
.bubble {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
padding: 8px 10px;
}
.own .bubble {
border-color: var(--neon-cyan);
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--fg-2);
font-size: 10px;
}
.meta strong {
color: var(--fg-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 5px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
}
.composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
textarea {
resize: none;
}
@media (max-width: 760px) {
.chat-backdrop {
background: rgba(0, 0, 0, 0.44);
}
.chat-drawer {
top: auto;
bottom: 0;
width: 100vw;
height: 55vh;
transform: translateY(105%);
border-left: 0;
border-top: 1px solid var(--line-bright);
}
.chat-drawer.open {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,116 @@
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
import { nip19 } from "nostr-tools";
import type { Filter } from "nostr-tools/filter";
import { SimplePool } from "nostr-tools/pool";
import { getActiveSigner, hasActiveSigner } from "./signer";
const RELAYS = ["wss://relay.primal.net"];
const CHAT_TAG = "gashboard-chat";
const pool = new SimplePool();
export type ChatMessage = {
id: string;
pubkey: string;
content: string;
createdAt: number;
};
export type ChatProfile = {
pubkey: string;
name: string;
picture: string;
};
export function canSendChat(): boolean {
return hasActiveSigner();
}
export function pubkeyFromNpub(npub: string | null): string | null {
if (!npub) return null;
try {
const decoded = nip19.decode(npub);
return decoded.type === "npub" ? decoded.data : null;
} catch {
return null;
}
}
export async function loadChat(): Promise<ChatMessage[]> {
const events = await pool.querySync(
RELAYS,
{ kinds: [1], "#t": [CHAT_TAG], limit: 80 },
{ maxWait: 3000 },
);
return dedupe(events.map(asMessage)).sort((a, b) => a.createdAt - b.createdAt);
}
export function subscribeChat(onMessage: (message: ChatMessage) => void): { close: () => void } {
const since = Math.floor(Date.now() / 1000) - 60;
return pool.subscribeMany(RELAYS, [{ kinds: [1], "#t": [CHAT_TAG], since }], {
onevent(event) {
onMessage(asMessage(event));
},
});
}
export async function sendChat(content: string): Promise<ChatMessage> {
const signer = getActiveSigner();
if (!signer) throw new Error("Chat needs the active signer. Log in again with your signer to send.");
const template: EventTemplate = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", CHAT_TAG],
["client", "gashboard"],
],
content,
};
const signed = await signer.signEvent(template);
const results = await Promise.allSettled(pool.publish(RELAYS, signed));
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("Could not publish chat message to relay");
}
return asMessage(signed);
}
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
const authors = [...new Set(pubkeys)].filter(Boolean);
if (!authors.length) return {};
const events = await pool.querySync(RELAYS, { kinds: [0], authors }, { maxWait: 2500 });
const latest = new Map<string, NostrEvent>();
for (const event of events) {
const prev = latest.get(event.pubkey);
if (!prev || event.created_at > prev.created_at) latest.set(event.pubkey, event);
}
const out: Record<string, ChatProfile> = {};
for (const [pubkey, event] of latest) {
try {
const meta = JSON.parse(event.content) as { name?: string; display_name?: string; picture?: string };
out[pubkey] = {
pubkey,
name: meta.display_name || meta.name || shortPubkey(pubkey),
picture: meta.picture || "",
};
} catch {
out[pubkey] = { pubkey, name: shortPubkey(pubkey), picture: "" };
}
}
return out;
}
export function shortPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
}
function asMessage(event: NostrEvent): ChatMessage {
return {
id: event.id,
pubkey: event.pubkey,
content: event.content,
createdAt: event.created_at,
};
}
function dedupe(messages: ChatMessage[]): ChatMessage[] {
return [...new Map(messages.map((m) => [m.id, m])).values()];
}

View File

@@ -24,6 +24,10 @@ export function getActiveSigner(): Signer | null {
return activeSigner;
}
export function hasActiveSigner(): boolean {
return activeSigner !== null;
}
export function clearSigner(): void {
activeSigner = null;
}