Persist signer sessions and expand chat stickers
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)}`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user