refactor: handling single websocket that handles all rooms the user is in, and added (not persistent) unread message count

This commit is contained in:
2026-01-16 11:36:09 +01:00
parent 7e2bf22e7b
commit f82a93e81e
8 changed files with 157 additions and 89 deletions

View File

@@ -48,7 +48,3 @@ export async function uploadAvatar(
xhr.send(fileData);
});
}
export function getAvatar(uuid: string): string {
return `${API}/account/get-avatar/${uuid}`;
}

View File

@@ -19,7 +19,7 @@ export function sendMessage(roomUuid: string, content: string) {
})
}
export async function getWsToken(roomUuid: string): Promise<string> {
const data = await apiFetch<{ token: string }>(`/ws/issue-token/rooms/${roomUuid}`);
return data.token;
export async function getWsToken(): Promise<string> {
const res = await apiFetch<{ token: string }>(`/ws/messages/issue-token`);
return res.token;
}

View File

@@ -1,5 +1,5 @@
<template>
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid=props.uuid
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid="props.uuid"
@close="showInviteModal = false" />
<div v-if="uuid === 'none'" class="no-room">
@@ -13,10 +13,10 @@
<h2 class="room-name">{{ currentRoom?.name }}</h2>
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
<MessageList v-if="isSocketConnected" :messages="messages" />
<MessageList v-if="messages.length > 0 || isSocketConnected" :messages="messages" />
</div>
<p v-if="!isSocketConnected" class="wait-msg">{{ $t('chat-connecting') }}</p>
<p v-if="messages.length === 0" class="wait-msg">{{ $t('chat-connecting') }}</p>
<div class="input-container">
<button v-if="isOwner && !currentRoom?.global" class="invite-btn" @click="showInviteModal = true"
@@ -26,7 +26,7 @@
<div v-if="connectionError" class="connection-error">
<p>{{ connectionError }}</p>
<button class="retry-btn" @click="initializeRoom">
<button class="retry-btn" @click="retryConnection">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
@@ -38,9 +38,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from "vue";
import { fetchMessages, sendMessage, getWsToken } from "../api/messages";
import { fetchMessages, sendMessage } from "../api/messages";
import type { Message, Room, User } from "../types";
import { API_WS } from '../main.ts';
import { apiFetch } from '../api/client';
import MessageList from "./MessageList.vue";
import MessageInput from "./MessageInput.vue";
import InvitePeopleModal from './InvitePeopleModal.vue';
@@ -51,6 +52,11 @@ import { useFluent } from 'fluent-vue';
const { $t } = useFluent();
const emit = defineEmits<{
// (e: 'send', content: string): void
(e: 'notification', roomUuid: string): void
}>();
const props = defineProps<{ uuid: string }>();
// UI State
@@ -76,37 +82,38 @@ const isOwner = computed(() => {
return currentUser.value.uuid === currentRoom.value.owner_uuid;
});
// Detaches listeners and attempts to close the socket.
async function cleanupWebSocket() {
isSocketConnected.value = false;
onMounted(async () => {
await connectGlobalWebSocket();
if (unlistenSocket) {
unlistenSocket();
unlistenSocket = null;
await loadRoomData();
window.addEventListener('keydown', handleGlobalKeyDown);
});
onUnmounted(async () => {
window.removeEventListener('keydown', handleGlobalKeyDown);
await cleanupWebSocket();
});
// Watch for room switches.
watch(() => props.uuid, async (newUuid, oldUuid) => {
if (newUuid !== oldUuid) {
await loadRoomData();
}
});
if (socket) {
const tempSocket = socket;
socket = null;
try {
await tempSocket.disconnect();
} catch (err) {
console.warn("Socket cleanup warning (non-fatal):", err);
}
}
async function retryConnection() {
await cleanupWebSocket();
await connectGlobalWebSocket();
await loadRoomData();
}
async function initializeRoom() {
await cleanupWebSocket();
async function loadRoomData() {
messages.value = [];
currentRoom.value = null;
hasMore.value = true;
connectionError.value = null;
isSocketConnected.value = false;
if (props.uuid === 'none') return;
try {
@@ -123,42 +130,50 @@ async function initializeRoom() {
await nextTick();
scrollToBottom();
await connectWebSocket();
} catch (err) {
console.error("Room initialization failed:", err);
connectionError.value = $t('chat-connecting-failed');
console.error("Room data load failed:", err);
}
}
async function connectWebSocket() {
try {
const wsToken = await getWsToken(props.uuid);
const url = `${API_WS}/rooms/${props.uuid}?token=${wsToken}`;
async function connectGlobalWebSocket() {
if (isSocketConnected.value) return;
try {
// Get a one-time token for the connection
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
const wsToken = res.token;
const url = `${API_WS}/messages?token=${wsToken}`;
socket = await WebSocket.connect(url);
isSocketConnected.value = true;
connectionError.value = null;
unlistenSocket = socket.addListener((msg) => {
if (msg.type === 'Text') {
try {
const data: Message = JSON.parse(msg.data);
// if (props.uuid !== 'none' && data.room_uuid && data.room_uuid !== props.uuid) {
// return;
// }
if (!messages.value.some(m => m.uuid === data.uuid)) {
messages.value.push(data);
nextTick().then(scrollToBottomIfAtEnd);
// Filter messages for the currenty open room
if (data.room_uuid === props.uuid) {
// Deduplicate
if (!messages.value.some(m => m.uuid === data.uuid)) {
messages.value.push(data);
nextTick().then(scrollToBottomIfAtEnd);
}
} else {
// Notifications for other rooms
emit('notification', data.room_uuid);
}
} catch (e) {
console.error("Error parsing message:", e);
}
} else if (msg.type === 'Close') {
isSocketConnected.value = false;
}
});
} catch (err) {
console.error("WS Connect failed:", err);
isSocketConnected.value = false;
@@ -166,6 +181,25 @@ async function connectWebSocket() {
}
}
async function cleanupWebSocket() {
isSocketConnected.value = false;
if (unlistenSocket) {
unlistenSocket();
unlistenSocket = null;
}
if (socket) {
const tempSocket = socket;
socket = null;
try {
await tempSocket.disconnect();
} catch (err) {
console.warn("Socket cleanup warning:", err);
}
}
}
async function handleScroll() {
const el = messageListRef.value;
if (!el) return;
@@ -237,23 +271,6 @@ async function onSend(content: string) {
if (props.uuid === 'none') return;
await sendMessage(props.uuid, content);
}
// Watch for room changes
watch(() => props.uuid, (newUuid, oldUuid) => {
if (newUuid !== oldUuid) {
initializeRoom();
}
});
onMounted(() => {
initializeRoom();
window.addEventListener('keydown', handleGlobalKeyDown);
});
onUnmounted(async () => {
window.removeEventListener('keydown', handleGlobalKeyDown);
await cleanupWebSocket();
});
</script>
<style scoped>
@@ -292,7 +309,6 @@ onUnmounted(async () => {
border-top: 1px solid var(--border);
}
/* Ensure the MessageInput component expands to fill the width */
:deep(.input-container > *:last-child) {
flex: 1;
}
@@ -302,13 +318,6 @@ onUnmounted(async () => {
text-align: center;
}
.loading-more {
text-align: center;
padding: 10px;
font-size: 0.8rem;
color: var(--muted);
}
.invite-btn {
margin: 0;
padding: 18px;

View File

@@ -12,18 +12,23 @@
</header>
<div class="wait-container" v-if="!rooms || rooms.length === 0">
<p class="wait-msg">{{ $t('chat-room-list-connecting') }}</p>
<button class="retry-btn" @click="refreshRooms()">
<i class="fa-solid fa-rotate-right"></i>
</button>
<p class="wait-msg">{{ $t('chat-room-list-connecting') }}</p>
<button class="retry-btn" @click="refreshRooms()">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<div class="scroll-area">
<router-link v-for="room in rooms" :key="room.uuid" :to="`/rooms/${room.uuid}`" class="btn room-item"
:class="{ active: route.params.uuid === room.uuid }" @click="emit('select-room')">
<div class="room-info">
<span class="room-name">{{ room.name }}</span>
<span class="room-owner">{{ $t('chat-room-owner', { owner: room.owner_name }) }}</span>
<div class="room-content-wrapper">
<div class="room-info">
<span class="room-name">{{ room.name }}</span>
<span class="room-owner">{{ $t('chat-room-owner', { owner: room.owner_name }) }}</span>
</div>
<div v-if="unreadCounts[room.uuid] && unreadCounts[room.uuid] > 0" class="unread-badge">
{{ unreadCounts[room.uuid] > 99 ? '99+' : unreadCounts[room.uuid] }}
</div>
</div>
</router-link>
</div>
@@ -31,22 +36,43 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { fetchRooms } from '../api/rooms';
import type { Room } from '../types';
import CreateRoomModal from './CreateRoomModal.vue';
const emit = defineEmits(['select-room']);
const route = useRoute();
const showCreate = ref(false);
const rooms = ref<Room[]>([]);
const unreadCounts = ref<Record<string, number>>({});
const emit = defineEmits(['select-room']);
async function refreshRooms() {
rooms.value = await fetchRooms();
rooms.value = await fetchRooms();
}
const incrementUnread = (roomUuid: string) => {
if (route.params.uuid === roomUuid) return;
const current = unreadCounts.value[roomUuid] || 0;
unreadCounts.value[roomUuid] = current + 1;
};
defineExpose({ incrementUnread });
watch(
() => route.params.uuid,
(newUuid) => {
if (typeof newUuid === 'string') {
unreadCounts.value[newUuid] = 0;
}
},
{ immediate: true }
);
onMounted(async () => {
rooms.value = await fetchRooms();
});
@@ -93,6 +119,29 @@ onMounted(async () => {
background: rgba(255, 255, 255, 0.05);
}
.room-content-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.unread-badge {
background-color: var(--accent);
color: white;
font-size: 0.75rem;
font-weight: bold;
min-width: 20px;
height: 20px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
margin-left: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.rooms-header {
padding: 15px;
display: flex;

View File

@@ -6,14 +6,14 @@
<aside class="sidebar" :class="{ 'is-open': isSidebarOpen }">
<div class="sidebar-content">
<RoomList @select-room="handleRoomSelection" />
<RoomList ref="roomListRef" @select-room="handleRoomSelection" />
</div>
</aside>
<div v-if="isSidebarOpen" class="sidebar-overlay" @click="isSidebarOpen = false"></div>
<main class="chat-window-container" :class="{ 'sidebar-is-open': isSidebarOpen }">
<ChatWindow :uuid="uuid" />
<ChatWindow :uuid="uuid" @notification="handleNotification" />
</main>
</div>
</template>
@@ -26,11 +26,19 @@ import ChatWindow from "../components/ChatWindow.vue";
defineProps<{ uuid: string }>();
const isSidebarOpen = ref(true);
const roomListRef = ref<InstanceType<typeof RoomList> | null>(null);
const handleRoomSelection = () => {
if (window.innerWidth <= 720) {
isSidebarOpen.value = false;
}
};
const handleNotification = (roomUuid: string) => {
if (roomListRef.value) {
roomListRef.value.incrementUnread(roomUuid);
}
};
</script>
<style scoped>

View File

@@ -4,10 +4,10 @@ import { ref, computed } from 'vue'
import { fetchFriendRequests } from './api/friends'
import { fetchRoomInvites } from './api/rooms'
import type { FriendRequest, RoomInvite } from './types'
import { getAvatar } from './api/account'
import { load, Store } from '@tauri-apps/plugin-store'
import { UpdateUserResponse } from './types'
import { reactive } from 'vue'
import { API } from './main'
let store: Store | null = null
export const initAuth = getAuthData
@@ -165,6 +165,11 @@ export function useNotifications() {
// A reactive object to store the last updated timestamp for each user
const avatarTimestamps = reactive<Record<string, number>>({})
export function getAvatar(uuid: string): string {
return `${API}/account/get-avatar/${uuid}`;
}
// Generates the avatar URL with a timestamp
export function getAvatarUrl(uuid: string | undefined | null) {
if (!uuid) return ''

View File

@@ -27,6 +27,7 @@ export interface Room {
export interface Message {
uuid: string
room_uuid: string
sender: string
sender_uuid: string
message_type: 'text'
@@ -52,5 +53,5 @@ export interface RoomInvite {
}
export interface VersionResponse {
version: string
version: string
}