Fix chat signer state and add stickers
This commit is contained in:
39
apps/web/src/chatStickers.ts
Normal file
39
apps/web/src/chatStickers.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user