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",
|
"name": "frangipane-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"backendVersion": "1.0.0",
|
"backendVersion": "1.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -48,7 +48,3 @@ export async function uploadAvatar(
|
|||||||
xhr.send(fileData);
|
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> {
|
export async function getWsToken(): Promise<string> {
|
||||||
const data = await apiFetch<{ token: string }>(`/ws/issue-token/rooms/${roomUuid}`);
|
const res = await apiFetch<{ token: string }>(`/ws/messages/issue-token`);
|
||||||
return data.token;
|
return res.token;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid=props.uuid
|
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid="props.uuid"
|
||||||
@close="showInviteModal = false" />
|
@close="showInviteModal = false" />
|
||||||
|
|
||||||
<div v-if="uuid === 'none'" class="no-room">
|
<div v-if="uuid === 'none'" class="no-room">
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
<h2 class="room-name">{{ currentRoom?.name }}</h2>
|
<h2 class="room-name">{{ currentRoom?.name }}</h2>
|
||||||
|
|
||||||
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
||||||
<MessageList v-if="isSocketConnected" :messages="messages" />
|
<MessageList v-if="messages.length > 0 || isSocketConnected" :messages="messages" />
|
||||||
</div>
|
</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">
|
<div class="input-container">
|
||||||
<button v-if="isOwner && !currentRoom?.global" class="invite-btn" @click="showInviteModal = true"
|
<button v-if="isOwner && !currentRoom?.global" class="invite-btn" @click="showInviteModal = true"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<div v-if="connectionError" class="connection-error">
|
<div v-if="connectionError" class="connection-error">
|
||||||
<p>{{ connectionError }}</p>
|
<p>{{ connectionError }}</p>
|
||||||
<button class="retry-btn" @click="initializeRoom">
|
<button class="retry-btn" @click="retryConnection">
|
||||||
<i class="fa-solid fa-rotate-right"></i>
|
<i class="fa-solid fa-rotate-right"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,9 +38,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from "vue";
|
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 type { Message, Room, User } from "../types";
|
||||||
import { API_WS } from '../main.ts';
|
import { API_WS } from '../main.ts';
|
||||||
|
import { apiFetch } from '../api/client';
|
||||||
import MessageList from "./MessageList.vue";
|
import MessageList from "./MessageList.vue";
|
||||||
import MessageInput from "./MessageInput.vue";
|
import MessageInput from "./MessageInput.vue";
|
||||||
import InvitePeopleModal from './InvitePeopleModal.vue';
|
import InvitePeopleModal from './InvitePeopleModal.vue';
|
||||||
@@ -51,6 +52,11 @@ import { useFluent } from 'fluent-vue';
|
|||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
// (e: 'send', content: string): void
|
||||||
|
(e: 'notification', roomUuid: string): void
|
||||||
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{ uuid: string }>();
|
const props = defineProps<{ uuid: string }>();
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
@@ -76,37 +82,38 @@ const isOwner = computed(() => {
|
|||||||
return currentUser.value.uuid === currentRoom.value.owner_uuid;
|
return currentUser.value.uuid === currentRoom.value.owner_uuid;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detaches listeners and attempts to close the socket.
|
onMounted(async () => {
|
||||||
async function cleanupWebSocket() {
|
await connectGlobalWebSocket();
|
||||||
isSocketConnected.value = false;
|
|
||||||
|
|
||||||
if (unlistenSocket) {
|
await loadRoomData();
|
||||||
unlistenSocket();
|
|
||||||
unlistenSocket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (socket) {
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
const tempSocket = socket;
|
});
|
||||||
socket = null;
|
|
||||||
|
|
||||||
try {
|
onUnmounted(async () => {
|
||||||
await tempSocket.disconnect();
|
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
} catch (err) {
|
await cleanupWebSocket();
|
||||||
console.warn("Socket cleanup warning (non-fatal):", err);
|
});
|
||||||
}
|
|
||||||
|
// 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() {
|
async function loadRoomData() {
|
||||||
await cleanupWebSocket();
|
|
||||||
|
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
currentRoom.value = null;
|
currentRoom.value = null;
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
connectionError.value = null;
|
connectionError.value = null;
|
||||||
|
|
||||||
isSocketConnected.value = false;
|
|
||||||
|
|
||||||
if (props.uuid === 'none') return;
|
if (props.uuid === 'none') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -123,42 +130,50 @@ async function initializeRoom() {
|
|||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
await connectWebSocket();
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Room initialization failed:", err);
|
console.error("Room data load failed:", err);
|
||||||
connectionError.value = $t('chat-connecting-failed');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectWebSocket() {
|
async function connectGlobalWebSocket() {
|
||||||
try {
|
if (isSocketConnected.value) return;
|
||||||
const wsToken = await getWsToken(props.uuid);
|
|
||||||
const url = `${API_WS}/rooms/${props.uuid}?token=${wsToken}`;
|
|
||||||
|
|
||||||
|
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);
|
socket = await WebSocket.connect(url);
|
||||||
|
|
||||||
isSocketConnected.value = true;
|
isSocketConnected.value = true;
|
||||||
|
connectionError.value = null;
|
||||||
|
|
||||||
unlistenSocket = socket.addListener((msg) => {
|
unlistenSocket = socket.addListener((msg) => {
|
||||||
if (msg.type === 'Text') {
|
if (msg.type === 'Text') {
|
||||||
try {
|
try {
|
||||||
const data: Message = JSON.parse(msg.data);
|
const data: Message = JSON.parse(msg.data);
|
||||||
|
|
||||||
// if (props.uuid !== 'none' && data.room_uuid && data.room_uuid !== props.uuid) {
|
// Filter messages for the currenty open room
|
||||||
// return;
|
if (data.room_uuid === props.uuid) {
|
||||||
// }
|
// Deduplicate
|
||||||
|
|
||||||
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
||||||
messages.value.push(data);
|
messages.value.push(data);
|
||||||
nextTick().then(scrollToBottomIfAtEnd);
|
nextTick().then(scrollToBottomIfAtEnd);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Notifications for other rooms
|
||||||
|
emit('notification', data.room_uuid);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error parsing message:", e);
|
console.error("Error parsing message:", e);
|
||||||
}
|
}
|
||||||
|
} else if (msg.type === 'Close') {
|
||||||
|
isSocketConnected.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("WS Connect failed:", err);
|
console.error("WS Connect failed:", err);
|
||||||
isSocketConnected.value = false;
|
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() {
|
async function handleScroll() {
|
||||||
const el = messageListRef.value;
|
const el = messageListRef.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -237,23 +271,6 @@ async function onSend(content: string) {
|
|||||||
if (props.uuid === 'none') return;
|
if (props.uuid === 'none') return;
|
||||||
await sendMessage(props.uuid, content);
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -292,7 +309,6 @@ onUnmounted(async () => {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure the MessageInput component expands to fill the width */
|
|
||||||
:deep(.input-container > *:last-child) {
|
:deep(.input-container > *:last-child) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -302,13 +318,6 @@ onUnmounted(async () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-more {
|
|
||||||
text-align: center;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite-btn {
|
.invite-btn {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
|
|||||||
@@ -21,32 +21,58 @@
|
|||||||
<div class="scroll-area">
|
<div class="scroll-area">
|
||||||
<router-link v-for="room in rooms" :key="room.uuid" :to="`/rooms/${room.uuid}`" class="btn room-item"
|
<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')">
|
:class="{ active: route.params.uuid === room.uuid }" @click="emit('select-room')">
|
||||||
|
<div class="room-content-wrapper">
|
||||||
<div class="room-info">
|
<div class="room-info">
|
||||||
<span class="room-name">{{ room.name }}</span>
|
<span class="room-name">{{ room.name }}</span>
|
||||||
<span class="room-owner">{{ $t('chat-room-owner', { owner: room.owner_name }) }}</span>
|
<span class="room-owner">{{ $t('chat-room-owner', { owner: room.owner_name }) }}</span>
|
||||||
</div>
|
</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>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { fetchRooms } from '../api/rooms';
|
import { fetchRooms } from '../api/rooms';
|
||||||
import type { Room } from '../types';
|
import type { Room } from '../types';
|
||||||
import CreateRoomModal from './CreateRoomModal.vue';
|
import CreateRoomModal from './CreateRoomModal.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-room']);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const showCreate = ref(false);
|
const showCreate = ref(false);
|
||||||
const rooms = ref<Room[]>([]);
|
const rooms = ref<Room[]>([]);
|
||||||
|
const unreadCounts = ref<Record<string, number>>({});
|
||||||
|
|
||||||
const emit = defineEmits(['select-room']);
|
|
||||||
|
|
||||||
async function refreshRooms() {
|
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 () => {
|
onMounted(async () => {
|
||||||
rooms.value = await fetchRooms();
|
rooms.value = await fetchRooms();
|
||||||
});
|
});
|
||||||
@@ -93,6 +119,29 @@ onMounted(async () => {
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
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 {
|
.rooms-header {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
<aside class="sidebar" :class="{ 'is-open': isSidebarOpen }">
|
<aside class="sidebar" :class="{ 'is-open': isSidebarOpen }">
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<RoomList @select-room="handleRoomSelection" />
|
<RoomList ref="roomListRef" @select-room="handleRoomSelection" />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div v-if="isSidebarOpen" class="sidebar-overlay" @click="isSidebarOpen = false"></div>
|
<div v-if="isSidebarOpen" class="sidebar-overlay" @click="isSidebarOpen = false"></div>
|
||||||
|
|
||||||
<main class="chat-window-container" :class="{ 'sidebar-is-open': isSidebarOpen }">
|
<main class="chat-window-container" :class="{ 'sidebar-is-open': isSidebarOpen }">
|
||||||
<ChatWindow :uuid="uuid" />
|
<ChatWindow :uuid="uuid" @notification="handleNotification" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -26,11 +26,19 @@ import ChatWindow from "../components/ChatWindow.vue";
|
|||||||
defineProps<{ uuid: string }>();
|
defineProps<{ uuid: string }>();
|
||||||
const isSidebarOpen = ref(true);
|
const isSidebarOpen = ref(true);
|
||||||
|
|
||||||
|
const roomListRef = ref<InstanceType<typeof RoomList> | null>(null);
|
||||||
|
|
||||||
const handleRoomSelection = () => {
|
const handleRoomSelection = () => {
|
||||||
if (window.innerWidth <= 720) {
|
if (window.innerWidth <= 720) {
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNotification = (roomUuid: string) => {
|
||||||
|
if (roomListRef.value) {
|
||||||
|
roomListRef.value.incrementUnread(roomUuid);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { ref, computed } from 'vue'
|
|||||||
import { fetchFriendRequests } from './api/friends'
|
import { fetchFriendRequests } from './api/friends'
|
||||||
import { fetchRoomInvites } from './api/rooms'
|
import { fetchRoomInvites } from './api/rooms'
|
||||||
import type { FriendRequest, RoomInvite } from './types'
|
import type { FriendRequest, RoomInvite } from './types'
|
||||||
import { getAvatar } from './api/account'
|
|
||||||
import { load, Store } from '@tauri-apps/plugin-store'
|
import { load, Store } from '@tauri-apps/plugin-store'
|
||||||
import { UpdateUserResponse } from './types'
|
import { UpdateUserResponse } from './types'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
import { API } from './main'
|
||||||
|
|
||||||
let store: Store | null = null
|
let store: Store | null = null
|
||||||
export const initAuth = getAuthData
|
export const initAuth = getAuthData
|
||||||
@@ -165,6 +165,11 @@ export function useNotifications() {
|
|||||||
// A reactive object to store the last updated timestamp for each user
|
// A reactive object to store the last updated timestamp for each user
|
||||||
const avatarTimestamps = reactive<Record<string, number>>({})
|
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
|
// Generates the avatar URL with a timestamp
|
||||||
export function getAvatarUrl(uuid: string | undefined | null) {
|
export function getAvatarUrl(uuid: string | undefined | null) {
|
||||||
if (!uuid) return ''
|
if (!uuid) return ''
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface Room {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
uuid: string
|
uuid: string
|
||||||
|
room_uuid: string
|
||||||
sender: string
|
sender: string
|
||||||
sender_uuid: string
|
sender_uuid: string
|
||||||
message_type: 'text'
|
message_type: 'text'
|
||||||
|
|||||||
Reference in New Issue
Block a user