Add Nostr chat drawer
This commit is contained in:
@@ -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'"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
306
apps/web/src/components/ChatDrawer.vue
Normal file
306
apps/web/src/components/ChatDrawer.vue
Normal 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>
|
||||
116
apps/web/src/services/chat.ts
Normal file
116
apps/web/src/services/chat.ts
Normal 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()];
|
||||
}
|
||||
@@ -24,6 +24,10 @@ export function getActiveSigner(): Signer | null {
|
||||
return activeSigner;
|
||||
}
|
||||
|
||||
export function hasActiveSigner(): boolean {
|
||||
return activeSigner !== null;
|
||||
}
|
||||
|
||||
export function clearSigner(): void {
|
||||
activeSigner = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user