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.
296 lines
9.9 KiB
Vue
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>
|