Fix chat signer state and add stickers

This commit is contained in:
Dorian
2026-05-06 20:05:08 +01:00
parent 1e74719932
commit e74f33f971
5 changed files with 142 additions and 10 deletions

View File

@@ -0,0 +1,39 @@
export type ChatSticker = {
label: string;
text: string;
};
export const STICKER_PREFIX = "[gashboard sticker]";
export const CHAT_STICKERS: ChatSticker[] = [
{ label: "fiat heater", text: "FIAT HEATER DETECTED: warm room, cold wallet." },
{ label: "no hashes", text: "NO HASHES. JUST GHEI. just fiat and a pension." },
{ label: "cope block", text: "BLOCK FOUND: not by us, obviously. carry on pretending." },
{ label: "desk miner", text: "DESK MINER ENERGY: tiny fan, enormous delusion." },
{ label: "pool bully", text: "BIG POOL ENTERED CHAT: scaled up to not shame the children." },
{ label: "paper hands", text: "PAPER HANDS, PLASTIC HEATER, ZERO SATS." },
{ label: "nonce cult", text: "NONCE CULT MEETING: attendance low, conviction insane." },
{ label: "fiat friend", text: "YOUR FIAT FRIEND THINKS THE HEATER IS AN INVESTMENT." },
{ label: "warm tonight", text: "SOMEONE'S GETTING WARMED TONIGHT. maybe not paid." },
{ label: "difficulty", text: "DIFFICULTY ADJUSTMENT: reality filed another complaint." },
{ label: "best calc", text: "BEST CALC: almost important, spiritually devastating." },
{ label: "hash high", text: "HASHRATE UP. ODDS STILL NEED A TELESCOPE." },
{ label: "pension", text: "JUST FIAT AND A PENSION: the saddest proof-of-warmth." },
{ label: "sovereign", text: "SOVEREIGN MINER: no block, no boss, no apology." },
{ label: "amber", text: "SIGNED BY A REAL SIGNER, UNLIKE YOUR BOOMER'S DIRECT DEBIT." },
{ label: "mempool", text: "MEMPOOL SAYS NO. DASHBOARD SAYS LOL." },
{ label: "reject", text: "REJECTED SHARE: even the server had standards." },
{ 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." },
];
export function stickerContent(sticker: ChatSticker): string {
return `${STICKER_PREFIX} ${sticker.text}`;
}
export function unsticker(content: string): string | null {
return content.startsWith(`${STICKER_PREFIX} `)
? content.slice(STICKER_PREFIX.length + 1)
: null;
}

View File

@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useAuthStore } from "../stores/auth";
import * as chat from "../services/chat";
import type { ChatMessage, ChatProfile } from "../services/chat";
import { CHAT_STICKERS, stickerContent, unsticker, type ChatSticker } from "../chatStickers";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
@@ -13,6 +14,7 @@ const profiles = ref<Record<string, ChatProfile>>({});
const draft = ref("");
const error = ref("");
const loading = ref(false);
const showStickers = ref(false);
const listEl = ref<HTMLElement | null>(null);
let sub: { close: () => void } | null = null;
@@ -81,11 +83,20 @@ async function loadMissingProfiles(): Promise<void> {
async function send(): Promise<void> {
const content = draft.value.trim();
if (!content) return;
await sendContent(content);
draft.value = "";
}
async function sendSticker(sticker: ChatSticker): Promise<void> {
await sendContent(stickerContent(sticker));
showStickers.value = false;
}
async function sendContent(content: string): Promise<void> {
error.value = "";
try {
const message = await chat.sendChat(content);
if (!messages.value.some((m) => m.id === message.id)) messages.value.push(message);
draft.value = "";
await loadMissingProfiles();
await scrollBottom();
} catch (e) {
@@ -101,6 +112,10 @@ function timeLabel(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function stickerText(content: string): string | null {
return unsticker(content);
}
async function scrollBottom(): Promise<void> {
await nextTick();
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight;
@@ -138,12 +153,35 @@ async function scrollBottom(): Promise<void> {
<strong>{{ profile(message.pubkey).name }}</strong>
<span>{{ timeLabel(message.createdAt) }}</span>
</div>
<p>{{ message.content }}</p>
<p v-if="!stickerText(message.content)">{{ message.content }}</p>
<p v-else class="sticker">{{ stickerText(message.content) }}</p>
</div>
</article>
</div>
<form class="composer" @submit.prevent="send">
<div class="composer-tools">
<button
type="button"
class="thin"
:disabled="!canSend"
@click="showStickers = !showStickers"
>
stickers
</button>
<span class="muted">{{ CHAT_STICKERS.length }} signed insults ready</span>
</div>
<div v-if="showStickers" class="sticker-tray">
<button
v-for="sticker in CHAT_STICKERS"
:key="sticker.label"
type="button"
:disabled="!canSend"
@click="sendSticker(sticker)"
>
{{ sticker.label }}
</button>
</div>
<textarea
v-model="draft"
:disabled="!canSend"
@@ -278,12 +316,53 @@ p {
overflow-wrap: anywhere;
font-size: 12px;
}
p.sticker {
border: 1px solid var(--neon-magenta);
color: var(--neon-amber);
padding: 10px;
font-size: 13px;
line-height: 1.35;
text-transform: uppercase;
box-shadow: inset 0 0 18px rgba(255, 61, 240, 0.08);
}
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
gap: 8px;
align-items: start;
}
.composer-tools,
.sticker-tray {
grid-column: 1 / -1;
}
.composer-tools {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.composer-tools span {
font-size: 10px;
text-align: right;
}
.sticker-tray {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
max-height: 132px;
overflow: auto;
padding: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
}
.sticker-tray button {
min-width: 0;
padding: 7px 8px;
color: var(--neon-amber);
border-color: var(--line-bright);
font-size: 10px;
overflow-wrap: anywhere;
}
textarea {
height: 58px;
min-height: 58px;

View File

@@ -5,6 +5,7 @@
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
import type { Filter } from "nostr-tools/filter";
import { SimplePool } from "nostr-tools/pool";
import { readonly, ref } from "vue";
export type SignerKind = "extension" | "bunker";
@@ -14,31 +15,33 @@ export type Signer = {
signEvent(template: EventTemplate): Promise<NostrEvent>;
};
let activeSigner: Signer | null = null;
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 pool = new SimplePool();
export function getActiveSigner(): Signer | null {
return activeSigner;
return activeSigner.value;
}
export function hasActiveSigner(): boolean {
return activeSigner !== null;
return activeSigner.value !== null;
}
export function clearSigner(): void {
activeSigner = null;
activeSigner.value = null;
}
export const activeSignerState = readonly(activeSigner);
export async function loginWithExtension(): Promise<string> {
if (!window.nostr) {
throw new Error("No NIP-07 extension found. Try Alby, nos2x, or Primal extension.");
}
const ext = window.nostr;
const pubkey = await ext.getPublicKey();
activeSigner = {
activeSigner.value = {
kind: "extension",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
@@ -68,7 +71,7 @@ export async function loginWithBunker(bunkerUri: string): Promise<string> {
}
const signer = await fromUri.call(Ctor, bunkerUri);
const pubkey: string = await signer.getPublicKey();
activeSigner = {
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
@@ -140,7 +143,7 @@ async function connectRemoteApp(opts: { openApp: boolean }): Promise<string> {
await signer.waitForSigner(abort.signal);
if (!signer.remote) throw new Error("Remote signer did not complete the connection");
const pubkey: string = await signer.getPublicKey();
activeSigner = {
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>

View File

@@ -11,6 +11,7 @@ export const useAuthStore = defineStore("auth", () => {
const busy = ref(false);
const isLoggedIn = computed(() => !!token.value);
const hasActiveSigner = computed(() => signer.activeSignerState.value !== null);
async function loginExtension(): Promise<void> {
error.value = null;
@@ -98,6 +99,7 @@ export const useAuthStore = defineStore("auth", () => {
error,
busy,
isLoggedIn,
hasActiveSigner,
loginExtension,
loginBunker,
loginRemoteApp,

View File

@@ -6,7 +6,16 @@ import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
onMounted(() => {
onMounted(async () => {
if (auth.hasPendingRemoteAppLogin()) {
try {
await auth.resumeRemoteAppLogin();
} catch {
/* surfaced on the login screen if resume fails */
}
void router.replace({ name: auth.isLoggedIn ? "dashboard" : "login", query: auth.isLoggedIn ? {} : { remote: "return" } });
return;
}
if (auth.isLoggedIn) {
void router.replace({ name: "dashboard" });
return;