diff --git a/apps/web/src/chatStickers.ts b/apps/web/src/chatStickers.ts index fe2d9b0..911ed4b 100644 --- a/apps/web/src/chatStickers.ts +++ b/apps/web/src/chatStickers.ts @@ -1,9 +1,11 @@ export type ChatSticker = { label: string; text: string; + kind?: "sticker" | "gif"; }; export const STICKER_PREFIX = "[gashboard sticker]"; +const GIF_MARKER = "[gif]"; export const CHAT_STICKERS: ChatSticker[] = [ { label: "fiat heater", text: "FIAT HEATER DETECTED: warm room, cold wallet." }, @@ -26,14 +28,37 @@ export const CHAT_STICKERS: ChatSticker[] = [ { label: "bigpapa", text: "BIGPAPA IS WATCHING. probably collecting fuck all." }, { label: "lottery", text: "SOLO LOTTERY TICKET PURCHASED WITH ELECTRICITY AND DENIAL." }, { label: "fiat people", text: "FIAT PEOPLE SEE A HEATER. MINERS SEE A COWARD." }, + { label: "gif cope", kind: "gif", text: "LIVE FOOTAGE OF ME PRETENDING THE ODDS ARE FINE." }, + { label: "gif panic", kind: "gif", text: "THE HASHRATE MOVED 0.01 TH/S AND I HAVE BECOME UNBEARABLE." }, + { label: "gif refresh", kind: "gif", text: "REFRESHING THE DASHBOARD LIKE IT OWES ME MONEY." }, + { label: "gif chart", kind: "gif", text: "GRAPH GOES UP. BRAIN GOES OFF." }, + { label: "gif no block", kind: "gif", text: "NO BLOCK? MUST BE A CONSPIRACY AGAINST MY USB PORT." }, + { label: "gif office", kind: "gif", text: "CORPORATE NEEDS YOU TO FIND THE DIFFERENCE: HEATER / MINER." }, + { label: "gif maths", kind: "gif", text: "ME EXPLAINING 1 IN 2 BILLION LIKE IT IS A PLAN." }, + { label: "gif deploy", kind: "gif", text: "DEPLOYED TO PRODUCTION. MAY GOD REVIEW THE DIFF." }, + { label: "gif smug", kind: "gif", text: "I MINE SOLO, WHICH IS HOW YOU KNOW I AM DIFFICULT AT DINNER." }, + { label: "gif invoice", kind: "gif", text: "ELECTRIC BILL ARRIVED. IT IS NOW A WHITEPAPER." }, + { label: "ngmi heater", text: "NGMI: NOT GENERATING MINING INCOME." }, + { label: "printer money", text: "FIAT PRINTER WENT BRRR. MINER WENT PLEASE NOTICE ME." }, + { label: "normie mode", text: "NORMIE MODE: PAYING FOR HEAT WITHOUT EVEN ATTEMPTING A BLOCK." }, + { label: "bullish fan", text: "LOUD FAN, QUIET BALANCE, MAXIMUM CONVICTION." }, + { label: "chart crime", text: "THIS CHART IS A CRIME SCENE WITH AXES." }, + { label: "share cope", text: "ACCEPTED SHARE: A PARTICIPATION TROPHY WITH VOLTAGE." }, + { label: "stack sats", text: "STACKING SATS? NO. STACKING RECEIPTS FROM THE POWER COMPANY." }, + { label: "main character", text: "MAIN CHARACTER ENERGY, BACKGROUND HASHRATE." }, + { label: "block soon", text: "BLOCK SOON. SOURCE: THE SAME PART OF MY BRAIN THAT BUYS TOPS." }, + { label: "heater max", text: "HEATER ON MAX. HASHES ON VACATION." }, ]; export function stickerContent(sticker: ChatSticker): string { - return `${STICKER_PREFIX} ${sticker.text}`; + return `${STICKER_PREFIX} ${sticker.kind === "gif" ? `${GIF_MARKER} ` : ""}${sticker.text}`; } -export function unsticker(content: string): string | null { - return content.startsWith(`${STICKER_PREFIX} `) - ? content.slice(STICKER_PREFIX.length + 1) - : null; +export function unsticker(content: string): { text: string; kind: "sticker" | "gif" } | null { + if (!content.startsWith(`${STICKER_PREFIX} `)) return null; + const body = content.slice(STICKER_PREFIX.length + 1); + if (body.startsWith(`${GIF_MARKER} `)) { + return { text: body.slice(GIF_MARKER.length + 1), kind: "gif" }; + } + return { text: body, kind: "sticker" }; } diff --git a/apps/web/src/components/ChatDrawer.vue b/apps/web/src/components/ChatDrawer.vue index 865f3e8..fb42fcb 100644 --- a/apps/web/src/components/ChatDrawer.vue +++ b/apps/web/src/components/ChatDrawer.vue @@ -19,7 +19,7 @@ const listEl = ref(null); let sub: { close: () => void } | null = null; const ownPubkey = computed(() => chat.pubkeyFromNpub(auth.npub)); -const canSend = computed(() => chat.canSendChat()); +const canSend = computed(() => auth.hasActiveSigner); onMounted(() => { if (auth.isLoggedIn) void start(); @@ -45,7 +45,10 @@ watch( watch( () => props.open, (open) => { - if (open) void scrollBottom(); + if (open) { + if (!canSend.value) void auth.restoreSavedSigner(); + void scrollBottom(); + } }, ); @@ -113,7 +116,11 @@ function timeLabel(ts: number): string { } function stickerText(content: string): string | null { - return unsticker(content); + return unsticker(content)?.text ?? null; +} + +function stickerKind(content: string): "sticker" | "gif" | null { + return unsticker(content)?.kind ?? null; } async function scrollBottom(): Promise { @@ -133,8 +140,13 @@ async function scrollBottom(): Promise { -
- Logged in, but the signer is not active in this tab. Reading works; sending needs a fresh signer login. +
+ + Logged in, but the signer is not active in this tab. Reading works; sending needs a restored signer. + +
{{ error }}
@@ -154,7 +166,7 @@ async function scrollBottom(): Promise { {{ timeLabel(message.createdAt) }}

{{ message.content }}

-

{{ stickerText(message.content) }}

+

{{ stickerText(message.content) }}

@@ -206,7 +218,8 @@ async function scrollBottom(): Promise { right: 0; z-index: 20; width: min(430px, 92vw); - height: min(720px, calc(100dvh - 92px)); + height: calc(100dvh - 92px); + max-height: 820px; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 12px; @@ -242,12 +255,24 @@ h2 { border-color: var(--neon-red); color: var(--neon-red); } +.notice.reconnect { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.notice.reconnect span { + min-width: 0; +} +.notice.reconnect button { + flex: 0 0 auto; +} .messages { min-height: 0; overflow: auto; display: flex; flex-direction: column; - gap: 10px; + gap: 14px; padding-right: 4px; } .empty { @@ -324,7 +349,14 @@ p.sticker { text-transform: uppercase; box-shadow: inset 0 0 18px rgba(255, 61, 240, 0.08); } +p.sticker.gif { + animation: sticker-bounce 1.4s steps(2, end) infinite; + background: + linear-gradient(90deg, rgba(255, 61, 240, 0.12), rgba(41, 255, 230, 0.08)), + rgba(255, 255, 255, 0.025); +} .composer { + position: relative; display: grid; grid-template-columns: 78px minmax(0, 1fr) 64px; gap: 8px; @@ -339,10 +371,15 @@ p.sticker { min-width: 0; } .sticker-tray { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 8px); + z-index: 2; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; - max-height: 132px; + max-height: min(260px, 42dvh); overflow: auto; padding: 8px; border: 1px solid var(--line); @@ -376,6 +413,17 @@ textarea { height: 58px; padding: 0 8px; } +@keyframes sticker-bounce { + 0%, + 100% { + transform: translate(0, 0); + filter: hue-rotate(0deg); + } + 50% { + transform: translate(1px, -1px); + filter: hue-rotate(22deg); + } +} @media (max-width: 760px) { .chat-backdrop { background: rgba(0, 0, 0, 0.44); @@ -394,11 +442,15 @@ textarea { .chat-drawer.open { transform: translateY(0); } + .messages { + gap: 10px; + } .composer { grid-template-columns: 72px minmax(0, 1fr) 58px; gap: 6px; } .sticker-tray { + grid-template-columns: repeat(2, minmax(0, 1fr)); max-height: 108px; } } diff --git a/apps/web/src/services/signer.ts b/apps/web/src/services/signer.ts index d33b298..72df8e3 100644 --- a/apps/web/src/services/signer.ts +++ b/apps/web/src/services/signer.ts @@ -19,6 +19,7 @@ const activeSigner = ref(null); const NOSTR_CONNECT_RELAYS = ["wss://relay.primal.net"]; const NOSTR_CONNECT_TIMEOUT_MS = 120_000; const NOSTR_CONNECT_PENDING_KEY = "gashboard.nostrconnect.pending"; +const SIGNER_SESSION_KEY = "gashboard.signer.session"; const pool = new SimplePool(); export function getActiveSigner(): Signer | null { @@ -31,6 +32,7 @@ export function hasActiveSigner(): boolean { export function clearSigner(): void { activeSigner.value = null; + clearSavedSignerSession(); } export const activeSignerState = readonly(activeSigner); @@ -52,6 +54,7 @@ export async function loginWithExtension(): Promise { content: template.content, })) as NostrEvent, }; + saveSignerSession({ kind: "extension", pubkey }); return pubkey; } @@ -71,12 +74,21 @@ export async function loginWithBunker(bunkerUri: string): Promise { } const signer = await fromUri.call(Ctor, bunkerUri); const pubkey: string = await signer.getPublicKey(); + const remote = signer.remote || pubkey; activeSigner.value = { kind: "bunker", getPublicKey: async () => pubkey, signEvent: async (template: EventTemplate) => (await signer.signEvent(template)) as NostrEvent, }; + saveSignerSession({ + kind: "bunker", + key: Array.from(signer.signer.key), + secret: signer.secret, + relays: signer.relays, + remote, + pubkey, + }); return pubkey; } @@ -149,6 +161,14 @@ async function connectRemoteApp(opts: { openApp: boolean }): Promise { signEvent: async (template: EventTemplate) => (await signer.signEvent(template)) as NostrEvent, }; + saveSignerSession({ + kind: "bunker", + key: Array.from(clientSigner.key), + secret: signer.secret, + relays: signer.relays, + remote: signer.remote, + pubkey, + }); clearPendingRemoteLogin(); return pubkey; } finally { @@ -157,12 +177,81 @@ async function connectRemoteApp(opts: { openApp: boolean }): Promise { } } +export async function restoreSavedSigner(): Promise { + if (activeSigner.value) return true; + const saved = loadSavedSignerSession(); + if (!saved) return false; + + if (saved.kind === "extension") { + if (!window.nostr) return false; + const ext = window.nostr; + const pubkey = await ext.getPublicKey(); + if (saved.pubkey && saved.pubkey !== pubkey) { + clearSavedSignerSession(); + return false; + } + activeSigner.value = { + kind: "extension", + getPublicKey: async () => pubkey, + signEvent: async (template: EventTemplate) => + (await ext.signEvent({ + kind: template.kind, + created_at: template.created_at, + tags: template.tags, + content: template.content, + })) as NostrEvent, + }; + saveSignerSession({ kind: "extension", pubkey }); + return true; + } + + const mod = await import("applesauce-signers"); + const NostrConnectSigner = + (mod as { NostrConnectSigner?: any }).NostrConnectSigner ?? + ((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner); + const PrivateKeySigner = + (mod as { PrivateKeySigner?: any }).PrivateKeySigner ?? + ((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner); + if (!NostrConnectSigner || !PrivateKeySigner) return false; + + const signer = new NostrConnectSigner({ + relays: saved.relays, + signer: new PrivateKeySigner(new Uint8Array(saved.key)), + secret: saved.secret, + remote: saved.remote, + pubkey: saved.pubkey, + subscriptionMethod: subscribeToRelays, + publishMethod: publishToRelays, + }); + activeSigner.value = { + kind: "bunker", + getPublicKey: async () => saved.pubkey, + signEvent: async (template: EventTemplate) => + (await signer.signEvent(template)) as NostrEvent, + }; + return true; +} + type PendingRemoteLogin = { key: number[]; secret: string; relays: string[]; }; +type SavedSignerSession = + | { + kind: "extension"; + pubkey: string; + } + | { + kind: "bunker"; + key: number[]; + secret: string; + relays: string[]; + remote: string; + pubkey: string; + }; + function loadPendingRemoteLogin(): PendingRemoteLogin | null { try { const raw = sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY); @@ -183,6 +272,36 @@ function clearPendingRemoteLogin(): void { sessionStorage.removeItem(NOSTR_CONNECT_PENDING_KEY); } +function loadSavedSignerSession(): SavedSignerSession | null { + try { + const raw = localStorage.getItem(SIGNER_SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as SavedSignerSession; + if (parsed.kind === "extension" && parsed.pubkey) return parsed; + if ( + parsed.kind === "bunker" && + Array.isArray(parsed.key) && + parsed.secret && + Array.isArray(parsed.relays) && + parsed.remote && + parsed.pubkey + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +function saveSignerSession(session: SavedSignerSession): void { + localStorage.setItem(SIGNER_SESSION_KEY, JSON.stringify(session)); +} + +function clearSavedSignerSession(): void { + localStorage.removeItem(SIGNER_SESSION_KEY); +} + function withCallback(uri: string): string { const separator = uri.includes("?") ? "&" : "?"; return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`; diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index edb0038..addd3cc 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -9,10 +9,13 @@ export const useAuthStore = defineStore("auth", () => { const token = ref(stored?.token ?? null); const error = ref(null); const busy = ref(false); + const restoringSigner = ref(false); const isLoggedIn = computed(() => !!token.value); const hasActiveSigner = computed(() => signer.activeSignerState.value !== null); + if (token.value) void restoreSavedSigner(); + async function loginExtension(): Promise { error.value = null; busy.value = true; @@ -93,17 +96,32 @@ export const useAuthStore = defineStore("auth", () => { error.value = null; } + async function restoreSavedSigner(): Promise { + if (hasActiveSigner.value) return true; + restoringSigner.value = true; + try { + return await signer.restoreSavedSigner(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Could not restore signer"; + return false; + } finally { + restoringSigner.value = false; + } + } + return { npub, token, error, busy, + restoringSigner, isLoggedIn, hasActiveSigner, loginExtension, loginBunker, loginRemoteApp, resumeRemoteAppLogin, + restoreSavedSigner, hasPendingRemoteAppLogin: signer.hasPendingRemoteAppLogin, logout, };