refactor: handling single websocket that handles all rooms the user is in, and added (not persistent) unread message count
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"name": "frangipane-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"backendVersion": "1.0.0",
|
||||
"backendVersion": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -48,7 +48,3 @@ export async function uploadAvatar(
|
||||
xhr.send(fileData);
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvatar(uuid: string): string {
|
||||
return `${API}/account/get-avatar/${uuid}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
if (socket) {
|
||||
const tempSocket = socket;
|
||||
socket = null;
|
||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||
});
|
||||
|
||||
try {
|
||||
await tempSocket.disconnect();
|
||||
} catch (err) {
|
||||
console.warn("Socket cleanup warning (non-fatal):", err);
|
||||
}
|
||||
onUnmounted(async () => {
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
await cleanupWebSocket();
|
||||
});
|
||||
|
||||
// Watch for room switches.
|
||||
watch(() => props.uuid, async (newUuid, oldUuid) => {
|
||||
if (newUuid !== oldUuid) {
|
||||
await loadRoomData();
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
// }
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -21,32 +21,58 @@
|
||||
<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-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>
|
||||
</div>
|
||||
</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();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Room {
|
||||
|
||||
export interface Message {
|
||||
uuid: string
|
||||
room_uuid: string
|
||||
sender: string
|
||||
sender_uuid: string
|
||||
message_type: 'text'
|
||||
|
||||
Reference in New Issue
Block a user