Files
gashboard/apps/web/src/components/ChatDrawer.vue
2026-05-06 20:23:22 +01:00

515 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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: [] }>();
const auth = useAuthStore();
const messages = ref<ChatMessage[]>([]);
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;
const ownPubkey = computed(() => chat.pubkeyFromNpub(auth.npub));
const canSend = computed(() => auth.hasActiveSigner);
onMounted(() => {
if (auth.isLoggedIn) void start();
});
onUnmounted(() => {
sub?.close();
});
watch(
() => auth.isLoggedIn,
(loggedIn) => {
if (loggedIn) void start();
else {
sub?.close();
sub = null;
messages.value = [];
profiles.value = {};
}
},
);
watch(
() => props.open,
(open) => {
if (open) {
if (!canSend.value) void auth.restoreSavedSigner();
void scrollBottom();
}
},
);
async function start(): Promise<void> {
if (sub) return;
loading.value = true;
error.value = "";
try {
messages.value = await chat.loadChat();
await loadMissingProfiles();
sub = chat.subscribeChat((message) => {
if (!messages.value.some((m) => m.id === message.id)) {
messages.value.push(message);
messages.value.sort((a, b) => a.createdAt - b.createdAt);
void loadMissingProfiles();
void scrollBottom();
}
});
await scrollBottom();
} catch (e) {
error.value = e instanceof Error ? e.message : "Chat failed";
} finally {
loading.value = false;
}
}
async function loadMissingProfiles(): Promise<void> {
const authors = messages.value.map((m) => m.pubkey);
if (ownPubkey.value) authors.push(ownPubkey.value);
const missing = [...new Set(authors)].filter((p) => !profiles.value[p]);
if (!missing.length) return;
profiles.value = { ...profiles.value, ...(await chat.loadProfiles(missing)) };
}
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);
await loadMissingProfiles();
await scrollBottom();
} catch (e) {
if (e instanceof Error && /connect|signer|auth|missing remote|aborted/i.test(e.message)) {
await auth.restoreSavedSigner();
}
error.value = e instanceof Error ? e.message : "Could not send";
}
}
function profile(pubkey: string): ChatProfile {
return profiles.value[pubkey] ?? { pubkey, name: chat.shortPubkey(pubkey), picture: "" };
}
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)?.text ?? null;
}
function stickerKind(content: string): "sticker" | "gif" | null {
return unsticker(content)?.kind ?? null;
}
async function scrollBottom(): Promise<void> {
await nextTick();
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight;
}
</script>
<template>
<div v-if="open" class="chat-backdrop" @click="emit('close')" />
<aside :class="['chat-drawer', { open }]">
<header>
<div>
<div class="label">nostr chat</div>
<h2 class="glow-cyan">mining desk heckle box</h2>
</div>
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>
</header>
<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.busy" @click="auth.reconnectRemoteApp()">
{{ auth.busy ? "opening" : "reconnect" }}
</button>
</div>
<div v-if="error" class="notice err">{{ error }}</div>
<div ref="listEl" class="messages">
<div v-if="loading" class="empty muted">loading relay gossip...</div>
<div v-else-if="!messages.length" class="empty muted">no chat yet. terrifying discipline.</div>
<article
v-for="message in messages"
:key="message.id"
:class="['msg', { own: message.pubkey === ownPubkey }]"
>
<img v-if="profile(message.pubkey).picture" :src="profile(message.pubkey).picture" alt="" />
<div v-else class="avatar">{{ profile(message.pubkey).name.slice(0, 2).toUpperCase() }}</div>
<div class="bubble">
<div class="meta">
<strong>{{ profile(message.pubkey).name }}</strong>
<span>{{ timeLabel(message.createdAt) }}</span>
</div>
<p v-if="!stickerText(message.content)">{{ message.content }}</p>
<p v-else :class="['sticker', stickerKind(message.content)]">{{ stickerText(message.content) }}</p>
</div>
</article>
</div>
<div v-if="showStickers" class="sticker-modal" @click.self="showStickers = false">
<section class="sticker-dialog" aria-label="chat stickers">
<div class="sticker-head">
<div>
<div class="label">stickers</div>
<strong class="glow-amber">{{ CHAT_STICKERS.length }} signed insults</strong>
</div>
<button type="button" class="thin" aria-label="close stickers" @click="showStickers = false">×</button>
</div>
<div class="sticker-tray">
<button
v-for="sticker in CHAT_STICKERS"
:key="sticker.label"
type="button"
:disabled="!canSend"
@click="sendSticker(sticker)"
>
<span>{{ sticker.label }}</span>
<small>{{ sticker.text }}</small>
</button>
</div>
</section>
</div>
<form class="composer" @submit.prevent="send">
<div class="composer-tools">
<button
type="button"
class="sticker-toggle"
:disabled="!canSend"
@click="showStickers = !showStickers"
>
stickers
</button>
</div>
<textarea
v-model="draft"
:disabled="!canSend"
rows="2"
maxlength="500"
placeholder="type something irresponsible but signed..."
/>
<button class="primary" :disabled="!canSend || !draft.trim()">send</button>
</form>
</aside>
</template>
<style scoped>
.chat-backdrop {
position: fixed;
inset: 0;
z-index: 18;
background: rgba(0, 0, 0, 0.2);
}
.chat-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 20;
width: min(430px, 92vw);
height: 100dvh;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 12px;
padding: 16px;
background: rgba(7, 9, 15, 0.97);
border-left: 1px solid var(--line-bright);
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.45);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.chat-drawer.open {
transform: translateX(0);
}
header {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
h2 {
margin: 2px 0 0;
font-size: 18px;
letter-spacing: 0;
}
.notice {
border: 1px solid var(--line);
color: var(--fg-1);
padding: 9px 10px;
font-size: 11px;
line-height: 1.35;
}
.notice.err {
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: 14px;
padding-right: 4px;
}
.empty {
padding: 24px 0;
text-align: center;
font-size: 12px;
}
.msg {
display: grid;
grid-template-columns: 34px 1fr;
gap: 8px;
align-items: start;
}
.msg.own {
grid-template-columns: 1fr 34px;
}
.msg.own img,
.msg.own .avatar {
grid-column: 2;
grid-row: 1;
}
.msg.own .bubble {
grid-column: 1;
grid-row: 1;
}
img,
.avatar {
width: 34px;
height: 34px;
border: 1px solid var(--line-bright);
object-fit: cover;
}
.avatar {
display: grid;
place-items: center;
color: var(--neon-cyan);
background: var(--bg-2);
font-size: 10px;
font-weight: 700;
}
.bubble {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
padding: 8px 10px;
}
.own .bubble {
border-color: var(--neon-cyan);
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--fg-2);
font-size: 10px;
}
.meta strong {
color: var(--fg-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 5px 0 0;
white-space: pre-wrap;
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);
}
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 {
display: grid;
grid-template-columns: 78px minmax(0, 1fr) 64px;
gap: 8px;
align-items: start;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.composer-tools {
min-width: 0;
}
.sticker-modal {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
align-items: end;
padding: 16px;
background: rgba(0, 0, 0, 0.46);
}
.sticker-dialog {
min-height: 0;
max-height: min(520px, calc(100% - 24px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
padding: 12px;
border: 1px solid var(--neon-magenta);
background: rgba(7, 9, 15, 0.98);
box-shadow: var(--shadow-magenta);
}
.sticker-head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
.sticker-head strong {
display: block;
margin-top: 2px;
font-size: 14px;
}
.sticker-tray {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.sticker-tray button {
min-width: 0;
min-height: 74px;
display: flex;
flex-direction: column;
gap: 5px;
align-items: flex-start;
justify-content: flex-start;
padding: 9px 10px;
color: var(--neon-amber);
border-color: var(--line-bright);
font-size: 10px;
text-align: left;
overflow-wrap: anywhere;
}
.sticker-tray button span {
color: var(--neon-cyan);
}
.sticker-tray button small {
color: var(--fg-1);
font-size: 10px;
line-height: 1.25;
text-transform: none;
letter-spacing: 0.02em;
}
.sticker-toggle {
width: 100%;
height: 58px;
padding: 0 6px;
color: var(--neon-amber);
border-color: var(--line-bright);
font-size: 10px;
}
textarea {
height: 58px;
min-height: 58px;
max-height: 92px;
resize: none;
line-height: 1.3;
}
.composer button {
align-self: start;
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);
}
.chat-drawer {
top: auto;
bottom: 0;
width: 100vw;
height: 100dvh;
max-height: none;
padding: 14px 14px calc(14px + env(safe-area-inset-bottom));
transform: translateY(105%);
border-left: 0;
border-top: 1px solid var(--line-bright);
}
.chat-drawer.open {
transform: translateY(0);
}
.messages {
gap: 10px;
}
.composer {
grid-template-columns: 72px minmax(0, 1fr) 58px;
gap: 6px;
}
.sticker-modal {
padding: 10px;
}
.sticker-dialog {
max-height: calc(100% - 12px);
}
.sticker-tray {
grid-template-columns: 1fr;
}
}
</style>