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(
+
{{ auth.npub ? shortNpub(auth.npub) : "" }}
@@ -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;
}