diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 2d17bb0..b0c63cb 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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'"], diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 41710a4..acef477 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -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(
@@ -68,6 +73,8 @@ watch( + + diff --git a/apps/web/src/services/chat.ts b/apps/web/src/services/chat.ts new file mode 100644 index 0000000..d81bb86 --- /dev/null +++ b/apps/web/src/services/chat.ts @@ -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 { + 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 { + 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> { + 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(); + 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 = {}; + 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()]; +} diff --git a/apps/web/src/services/signer.ts b/apps/web/src/services/signer.ts index 3390e06..9e591e8 100644 --- a/apps/web/src/services/signer.ts +++ b/apps/web/src/services/signer.ts @@ -24,6 +24,10 @@ export function getActiveSigner(): Signer | null { return activeSigner; } +export function hasActiveSigner(): boolean { + return activeSigner !== null; +} + export function clearSigner(): void { activeSigner = null; }