drop/server/pages/community/chat.vue
wdunn001 e80033e68e
Some checks failed
Server CI / Lint (push) Failing after 2m23s
Server CI / Typecheck (push) Failing after 2m56s
community: M3 -- friends, presence, chat
Friendship (request/accept/decline/remove, self-request and crossing-request
handling, DB-level pair uniqueness via a hand-written expression index),
ChatRoom/ChatMessage/ChatReadState backed by Nitro's built-in websocket
(server/api/v1/community/ws.get.ts) behind a ChatTransport seam
(server/internal/community/chatTransport.ts) with a REST send fallback.

Mid-milestone product redirect: chat's primary job is coordinating the
persistent-world servers (EQEmu/WoW/DAoC), not per-title discussion --
ChatRoomKind gets a first-class `server` case and chatService.ts ships a
static KNOWN_SERVERS registry keyed the same way M4's GameServer will be,
once that lands (gameServerId reserved for that wiring-up pass).

Presence has no new table -- "online" is a live websocket registry
(presenceRuntime.ts), "playing X" reads through an isolated query module
(presenceService.ts) that degrades to an empty, clearly-labeled result if
PlaySession's shape doesn't match what it expects.

Pages: /community/friends, /community/chat, plus a friends/servers strip on
/community itself. Migration 20260803120000_m3_friends_chat.
2026-08-03 01:39:04 -04:00

296 lines
9.9 KiB
Vue

<template>
<div class="w-full h-[calc(100vh-4rem)] flex flex-row overflow-hidden">
<!-- Room list -->
<aside class="w-72 shrink-0 border-r border-zinc-800 flex flex-col overflow-y-auto">
<div class="px-4 py-4">
<h1 class="text-lg font-bold font-display text-zinc-100">
{{ $t("community.chat.title") }}
</h1>
</div>
<div v-for="group in roomGroups" :key="group.label" class="px-2 py-2">
<div class="px-2 pb-1 text-[10px] uppercase tracking-wide text-zinc-500 font-semibold">
{{ group.label }}
</div>
<button
v-for="room in group.rooms"
:key="room.id"
type="button"
class="w-full text-left px-2 py-2 rounded-md text-sm flex items-center justify-between duration-200"
:class="
activeRoomId === room.id
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'
"
@click="selectRoom(room.id)"
>
<span class="truncate">{{ room.name }}</span>
<span
v-if="room.unreadCount > 0"
class="ml-2 shrink-0 rounded-full bg-blue-600 text-white text-[10px] leading-4 px-1.5"
>
{{ room.unreadCount > 99 ? "99+" : room.unreadCount }}
</span>
</button>
</div>
</aside>
<!-- Message pane -->
<main class="flex-1 flex flex-col min-w-0">
<template v-if="activeRoom">
<header class="border-b border-zinc-800 px-6 py-4 flex flex-col">
<h2 class="text-zinc-100 font-bold font-display">{{ activeRoom.name }}</h2>
<p v-if="activeRoom.topic" class="text-zinc-500 text-xs mt-0.5">
{{ activeRoom.topic }}
</p>
</header>
<div ref="scrollEl" class="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-y-3">
<button
v-if="hasMoreHistory"
type="button"
class="self-center text-xs text-zinc-500 hover:text-zinc-300 duration-200"
@click="loadOlder"
>
{{ $t("community.chat.loadOlder") }}
</button>
<div
v-for="message in messages"
:key="message.clientNonce ?? message.id"
class="flex items-start gap-x-3"
:class="{ 'opacity-60': message.pending }"
>
<UserAvatar
:avatar-url="message.sender?.avatarUrl"
:name="message.sender?.displayName ?? '?'"
:size-px="28"
/>
<div class="flex flex-col min-w-0">
<div class="flex items-baseline gap-x-2">
<span class="text-zinc-100 text-sm font-semibold">{{
message.sender?.displayName ?? $t("community.chat.system")
}}</span>
<span class="text-zinc-600 text-[10px]">{{
formatTime(message.createdAt)
}}</span>
</div>
<p class="text-zinc-300 text-sm break-words whitespace-pre-wrap">
{{ message.deleted ? $t("community.chat.deleted") : message.body }}
</p>
</div>
</div>
<p v-if="messages.length === 0" class="text-zinc-500 text-sm">
{{ $t("community.chat.noMessages") }}
</p>
</div>
<form class="border-t border-zinc-800 p-4 flex gap-x-2" @submit.prevent="send">
<input
v-model="composer"
type="text"
:placeholder="$t('community.chat.composerPlaceholder')"
class="flex-1 rounded-md bg-zinc-800 border-0 text-zinc-100 placeholder:text-zinc-500 focus:ring-2 focus:ring-blue-600 px-3 py-2 text-sm"
@input="notifyTyping"
/>
<button
type="submit"
:disabled="!composer.trim()"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50 duration-200"
>
{{ $t("community.chat.send") }}
</button>
</form>
</template>
<div v-else class="flex-1 flex items-center justify-center text-zinc-500 text-sm">
{{ $t("community.chat.pickARoom") }}
</div>
</main>
</div>
</template>
<script setup lang="ts">
const { t } = useI18n();
useHead({ title: t("community.chat.title") });
interface ChatUserSummary {
id: string;
username: string;
displayName: string;
avatarUrl: string | null;
}
interface ChatRoomSummary {
id: string;
kind: "server" | "title" | "direct" | "global";
name: string;
topic: string | null;
serverKey: string | null;
gameId: string | null;
communityTitleId: string | null;
otherUser: ChatUserSummary | null;
lastMessageAt: string | null;
unreadCount: number;
}
interface ChatMessageDTO {
id: string;
roomId: string;
kind: "text" | "system";
body: string;
deleted: boolean;
clientNonce: string | null;
replyToId: string | null;
sender: ChatUserSummary | null;
createdAt: string;
editedAt: string | null;
pending?: boolean;
}
const rooms = ref<ChatRoomSummary[]>([]);
const serverRooms = computed(() => rooms.value.filter((r) => r.kind === "server"));
const generalRoom = computed(() => rooms.value.find((r) => r.kind === "global") ?? null);
const titleRooms = computed(() => rooms.value.filter((r) => r.kind === "title"));
const dmRooms = computed(() => rooms.value.filter((r) => r.kind === "direct"));
const roomGroups = computed(() =>
[
{ label: t("community.chat.servers"), rooms: serverRooms.value },
{ label: t("community.chat.general"), rooms: generalRoom.value ? [generalRoom.value] : [] },
{ label: t("community.chat.titles"), rooms: titleRooms.value },
{ label: t("community.chat.directMessages"), rooms: dmRooms.value },
].filter((group) => group.rooms.length > 0),
);
async function loadRooms() {
const res = await $dropFetch<{ rooms: ChatRoomSummary[] }>("/api/v1/community/chat/rooms");
rooms.value = res.rooms;
}
await loadRooms();
const activeRoomId = ref<string | null>(null);
const activeRoom = computed(() => rooms.value.find((r) => r.id === activeRoomId.value) ?? null);
const messages = ref<ChatMessageDTO[]>([]);
const hasMoreHistory = ref(false);
const scrollEl = ref<HTMLElement | null>(null);
const chat = useCommunityChatSocket();
let currentSubscribedRoom: string | null = null;
async function selectRoom(roomId: string) {
if (currentSubscribedRoom) chat.unsubscribe([`room:${currentSubscribedRoom}`]);
activeRoomId.value = roomId;
chat.subscribe([`room:${roomId}`]);
currentSubscribedRoom = roomId;
const res = await $dropFetch<{ messages: ChatMessageDTO[] }>(
`/api/v1/community/chat/rooms/${roomId}/messages`,
);
messages.value = res.messages;
hasMoreHistory.value = res.messages.length >= 50;
await nextTick(scrollToBottom);
const last = messages.value.at(-1);
if (last) {
chat.markRead(roomId, last.id);
const room = rooms.value.find((r) => r.id === roomId);
if (room) room.unreadCount = 0;
}
}
async function loadOlder() {
if (!activeRoomId.value || messages.value.length === 0) return;
const before = messages.value[0]?.id;
const res = await $dropFetch<{ messages: ChatMessageDTO[] }>(
`/api/v1/community/chat/rooms/${activeRoomId.value}/messages`,
{ params: before ? { before } : {} },
);
hasMoreHistory.value = res.messages.length >= 50;
messages.value = [...res.messages, ...messages.value];
}
function scrollToBottom() {
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
const currentUser = useUser();
const composer = ref("");
let typingSentAt = 0;
function notifyTyping() {
if (!activeRoomId.value) return;
const now = Date.now();
if (now - typingSentAt < 3000) return;
typingSentAt = now;
chat.sendTyping(activeRoomId.value);
}
function send() {
const roomId = activeRoomId.value;
const body = composer.value.trim();
if (!roomId || !body) return;
const clientNonce = crypto.randomUUID();
messages.value.push({
id: clientNonce,
roomId,
kind: "text",
body,
deleted: false,
clientNonce,
replyToId: null,
sender: currentUser.value
? {
id: currentUser.value.id,
username: currentUser.value.username,
displayName: currentUser.value.displayName || currentUser.value.username,
avatarUrl: currentUser.value.profilePictureObjectId
? useObject(currentUser.value.profilePictureObjectId)
: null,
}
: null,
createdAt: new Date().toISOString(),
editedAt: null,
pending: true,
});
chat.sendChatMessage(roomId, body, clientNonce);
composer.value = "";
nextTick(scrollToBottom);
}
chat.on((envelope) => {
if (envelope.t === "chat.message") {
const message = envelope.d as ChatMessageDTO;
const room = rooms.value.find((r) => r.id === message.roomId);
if (message.roomId === activeRoomId.value) {
const optimisticIndex = messages.value.findIndex(
(m) => m.pending && m.clientNonce && m.clientNonce === message.clientNonce,
);
if (optimisticIndex !== -1) messages.value.splice(optimisticIndex, 1, message);
else messages.value.push(message);
nextTick(scrollToBottom);
chat.markRead(message.roomId, message.id);
} else if (room) {
room.unreadCount += 1;
}
if (room) room.lastMessageAt = message.createdAt;
}
});
const route = useRoute();
const router = useRouter();
onMounted(async () => {
const dmTarget = route.query.dm as string | undefined;
const roomParam = route.query.room as string | undefined;
if (dmTarget) {
const res = await $dropFetch<{ roomId: string }>(`/api/v1/community/chat/dm/${dmTarget}`, {
method: "POST",
});
await loadRooms();
await selectRoom(res.roomId);
router.replace({ query: {} });
} else if (roomParam) {
await selectRoom(roomParam);
}
});
</script>