Persist signer sessions and expand chat stickers

This commit is contained in:
Dorian
2026-05-06 20:19:27 +01:00
parent 7b7685f0e8
commit b23c9006b7
4 changed files with 229 additions and 15 deletions

View File

@@ -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" };
}

View File

@@ -19,7 +19,7 @@ const listEl = ref<HTMLElement | null>(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<void> {
@@ -133,8 +140,13 @@ async function scrollBottom(): Promise<void> {
<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 v-if="!canSend" class="notice reconnect">
<span>
Logged in, but the signer is not active in this tab. Reading works; sending needs a restored signer.
</span>
<button class="thin" :disabled="auth.restoringSigner" @click="auth.restoreSavedSigner()">
{{ auth.restoringSigner ? "restoring" : "restore" }}
</button>
</div>
<div v-if="error" class="notice err">{{ error }}</div>
@@ -154,7 +166,7 @@ async function scrollBottom(): Promise<void> {
<span>{{ timeLabel(message.createdAt) }}</span>
</div>
<p v-if="!stickerText(message.content)">{{ message.content }}</p>
<p v-else class="sticker">{{ stickerText(message.content) }}</p>
<p v-else :class="['sticker', stickerKind(message.content)]">{{ stickerText(message.content) }}</p>
</div>
</article>
</div>
@@ -206,7 +218,8 @@ async function scrollBottom(): Promise<void> {
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;
}
}

View File

@@ -19,6 +19,7 @@ const activeSigner = ref<Signer | null>(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<string> {
content: template.content,
})) as NostrEvent,
};
saveSignerSession({ kind: "extension", pubkey });
return pubkey;
}
@@ -71,12 +74,21 @@ export async function loginWithBunker(bunkerUri: string): Promise<string> {
}
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<string> {
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<string> {
}
}
export async function restoreSavedSigner(): Promise<boolean> {
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`)}`;

View File

@@ -9,10 +9,13 @@ export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(stored?.token ?? null);
const error = ref<string | null>(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<void> {
error.value = null;
busy.value = true;
@@ -93,17 +96,32 @@ export const useAuthStore = defineStore("auth", () => {
error.value = null;
}
async function restoreSavedSigner(): Promise<boolean> {
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,
};