improved error handling and added retry buttons in chat page

This commit is contained in:
2026-01-15 22:57:44 +01:00
parent 376243f64c
commit 7e2bf22e7b
5 changed files with 179 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
<template> <template>
<InvitePeopleModal v-if="showInviteModal" :room_uuid=props.uuid @close="showInviteModal = false" /> <InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid=props.uuid
@close="showInviteModal = false" />
<div v-if="uuid === 'none'" class="no-room"> <div v-if="uuid === 'none'" class="no-room">
<div class="empty-state"> <div class="empty-state">
@@ -12,16 +13,25 @@
<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 :messages="messages" /> <MessageList v-if="isSocketConnected" :messages="messages" />
</div> </div>
<p v-if="!isSocketConnected" 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"
:title="$t('chat-invite-title')"> :title="$t('chat-invite-title')">
<i class="fa-solid fa-users"></i> <i class="fa-solid fa-users"></i>
</button> </button>
<MessageInput ref="messageInputRef" @send="onSend" /> <div v-if="connectionError" class="connection-error">
<p>{{ connectionError }}</p>
<button class="retry-btn" @click="initializeRoom">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<MessageInput v-if="isSocketConnected" ref="messageInputRef" @send="onSend" />
</div> </div>
</div> </div>
</template> </template>
@@ -37,37 +47,71 @@ import InvitePeopleModal from './InvitePeopleModal.vue';
import WebSocket from '@tauri-apps/plugin-websocket'; import WebSocket from '@tauri-apps/plugin-websocket';
import { getAuthData } from "../store.ts"; import { getAuthData } from "../store.ts";
import { fetchRoomInfo } from "../api/rooms.ts"; import { fetchRoomInfo } from "../api/rooms.ts";
import { useFluent } from 'fluent-vue';
const { $t } = useFluent();
const props = defineProps<{ uuid: string }>(); const props = defineProps<{ uuid: string }>();
// UI State
const messages = ref<Message[]>([]); const messages = ref<Message[]>([]);
const messageListRef = ref<HTMLElement | null>(null); const messageListRef = ref<HTMLElement | null>(null);
const messageInputRef = ref<InstanceType<typeof MessageInput> | null>(null); const messageInputRef = ref<InstanceType<typeof MessageInput> | null>(null);
const currentUser = ref<User | null>(null); const currentUser = ref<User | null>(null);
const currentRoom = ref<Room | null>(null); const currentRoom = ref<Room | null>(null);
const connectionError = ref<string | null>(null);
// Pagination State // Pagination State
const isLoadingMore = ref(false); const isLoadingMore = ref(false);
const hasMore = ref(true); // Assume there are more until API returns empty const hasMore = ref(true);
const showInviteModal = ref(false); const showInviteModal = ref(false);
// WebSocket State
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
const isSocketConnected = ref(false);
let unlistenSocket: (() => void) | null = null;
const isOwner = computed(() => { const isOwner = computed(() => {
if (!currentUser.value || !currentRoom.value) return false; if (!currentUser.value || !currentRoom.value) return false;
return currentUser.value.uuid === currentRoom.value.owner_uuid; return currentUser.value.uuid === currentRoom.value.owner_uuid;
}); });
// Detaches listeners and attempts to close the socket.
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 (non-fatal):", err);
}
}
}
async function initializeRoom() { async function initializeRoom() {
if (socket) { await socket.disconnect(); socket = null; } await cleanupWebSocket();
messages.value = []; messages.value = [];
hasMore.value = true;
currentRoom.value = null; currentRoom.value = null;
hasMore.value = true;
connectionError.value = null;
isSocketConnected.value = false;
if (props.uuid === 'none') return; if (props.uuid === 'none') return;
try { try {
const [msgs, roomInfo, auth] = await Promise.all([ const [msgs, roomInfo, auth] = await Promise.all([
fetchMessages(props.uuid, undefined, 40), // Load first 40 fetchMessages(props.uuid, undefined, 40),
fetchRoomInfo(props.uuid), fetchRoomInfo(props.uuid),
getAuthData() getAuthData()
]); ]);
@@ -80,21 +124,45 @@ async function initializeRoom() {
await nextTick(); await nextTick();
scrollToBottom(); scrollToBottom();
await connectWebSocket();
} catch (err) {
console.error("Room initialization failed:", err);
connectionError.value = $t('chat-connecting-failed');
}
}
async function connectWebSocket() {
try {
const wsToken = await getWsToken(props.uuid); const wsToken = await getWsToken(props.uuid);
const url = `${API_WS}/rooms/${props.uuid}?token=${wsToken}`; const url = `${API_WS}/rooms/${props.uuid}?token=${wsToken}`;
socket = await WebSocket.connect(url); socket = await WebSocket.connect(url);
socket.addListener((msg) => { isSocketConnected.value = true;
unlistenSocket = socket.addListener((msg) => {
if (msg.type === 'Text') { if (msg.type === 'Text') {
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) {
// return;
// }
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);
} }
} catch (e) {
console.error("Error parsing message:", e);
}
} }
}); });
} catch (err) { } catch (err) {
console.error("Room initialization failed:", err); console.error("WS Connect failed:", err);
isSocketConnected.value = false;
connectionError.value = "Live chat disconnected.";
} }
} }
@@ -102,7 +170,6 @@ async function handleScroll() {
const el = messageListRef.value; const el = messageListRef.value;
if (!el) return; if (!el) return;
// If user scrolls to the top, is not already loading, and there's more data
if (el.scrollTop < 50 && !isLoadingMore.value && hasMore.value) { if (el.scrollTop < 50 && !isLoadingMore.value && hasMore.value) {
await loadMore(); await loadMore();
} }
@@ -122,7 +189,6 @@ async function loadMore() {
return; return;
} }
// Capture height before adding messages to maintain scroll position
const el = messageListRef.value; const el = messageListRef.value;
const previousScrollHeight = el?.scrollHeight || 0; const previousScrollHeight = el?.scrollHeight || 0;
@@ -130,7 +196,6 @@ async function loadMore() {
await nextTick(); await nextTick();
// Restore scroll position so the view doesn't jump
if (el) { if (el) {
el.scrollTop = el.scrollHeight - previousScrollHeight; el.scrollTop = el.scrollHeight - previousScrollHeight;
} }
@@ -149,7 +214,6 @@ function scrollToBottom() {
} }
} }
// Only scroll to bottom for new messages if the user is already near the bottom
function scrollToBottomIfAtEnd() { function scrollToBottomIfAtEnd() {
const el = messageListRef.value; const el = messageListRef.value;
if (!el) return; if (!el) return;
@@ -158,8 +222,6 @@ function scrollToBottomIfAtEnd() {
if (isAtBottom) scrollToBottom(); if (isAtBottom) scrollToBottom();
} }
const handleGlobalKeyDown = (event: KeyboardEvent) => { const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
const active = document.activeElement?.tagName.toLowerCase(); const active = document.activeElement?.tagName.toLowerCase();
@@ -176,8 +238,11 @@ async function onSend(content: string) {
await sendMessage(props.uuid, content); await sendMessage(props.uuid, content);
} }
watch(() => props.uuid, () => { // Watch for room changes
watch(() => props.uuid, (newUuid, oldUuid) => {
if (newUuid !== oldUuid) {
initializeRoom(); initializeRoom();
}
}); });
onMounted(() => { onMounted(() => {
@@ -186,15 +251,14 @@ onMounted(() => {
}); });
onUnmounted(async () => { onUnmounted(async () => {
if (socket) {
await socket.disconnect();
}
window.removeEventListener('keydown', handleGlobalKeyDown); window.removeEventListener('keydown', handleGlobalKeyDown);
await cleanupWebSocket();
}); });
</script> </script>
<style scoped> <style scoped>
.chat-container { .chat-container {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
@@ -207,6 +271,19 @@ onUnmounted(async () => {
scroll-behavior: auto; scroll-behavior: auto;
} }
.wait-msg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
color: var(--muted);
font-size: 1.1rem;
z-index: 10;
pointer-events: none;
text-align: center;
}
.input-container { .input-container {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -247,6 +324,23 @@ onUnmounted(async () => {
justify-content: center; justify-content: center;
} }
.connection-error {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 10px;
}
.retry-btn {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.retry-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.no-room { .no-room {
height: 100%; height: 100%;

View File

@@ -1,4 +1,8 @@
<template> <template>
<Teleport to="body">
<CreateRoomModal v-if="showCreate" @close="showCreate = false" @created="rooms.push($event)" />
</Teleport>
<div class="room-list"> <div class="room-list">
<header class="rooms-header"> <header class="rooms-header">
<h2>{{ $t('chat-room-list-title') }}</h2> <h2>{{ $t('chat-room-list-title') }}</h2>
@@ -7,9 +11,12 @@
</button> </button>
</header> </header>
<Teleport to="body"> <div class="wait-container" v-if="!rooms || rooms.length === 0">
<CreateRoomModal v-if="showCreate" @close="showCreate = false" @created="rooms.push($event)" /> <p class="wait-msg">{{ $t('chat-room-list-connecting') }}</p>
</Teleport> <button class="retry-btn" @click="refreshRooms()">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<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"
@@ -36,6 +43,10 @@ const rooms = ref<Room[]>([]);
const emit = defineEmits(['select-room']); const emit = defineEmits(['select-room']);
async function refreshRooms() {
rooms.value = await fetchRooms();
}
onMounted(async () => { onMounted(async () => {
rooms.value = await fetchRooms(); rooms.value = await fetchRooms();
}); });
@@ -43,6 +54,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.room-list { .room-list {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
@@ -50,6 +62,37 @@ onMounted(async () => {
-webkit-user-select: none; -webkit-user-select: none;
} }
.wait-container {
display: flex;
flex-direction: column;
gap: 1.2rem;
align-items: center;
justify-content: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
}
.wait-msg {
color: var(--muted);
font-size: 1.1rem;
z-index: 5;
pointer-events: none;
text-align: center;
}
.retry-btn {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.retry-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.rooms-header { .rooms-header {
padding: 15px; padding: 15px;
display: flex; display: flex;

View File

@@ -46,6 +46,9 @@ import { readFile } from '@tauri-apps/plugin-fs';
import { refreshLocalUser } from '../store.ts'; import { refreshLocalUser } from '../store.ts';
import { getAuthData } from '../store.ts'; import { getAuthData } from '../store.ts';
import { refreshAvatar } from '../store.ts'; import { refreshAvatar } from '../store.ts';
import { useFluent } from 'fluent-vue';
const { $t } = useFluent();
const emit = defineEmits(['close', 'updated']); const emit = defineEmits(['close', 'updated']);
@@ -110,7 +113,7 @@ async function setFile(path: string) {
errorMessage.value = ''; errorMessage.value = '';
} catch (e) { } catch (e) {
console.error("Error reading file:", e); console.error("Error reading file:", e);
errorMessage.value = "Could not read image file."; errorMessage.value = $t('settings-error-upload-avatar-failed-read')
} }
} }
@@ -144,7 +147,7 @@ async function handleUpload() {
emit('close'); emit('close');
} catch (err: any) { } catch (err: any) {
console.error("Upload failed:", err); console.error("Upload failed:", err);
errorMessage.value = err.message || 'Failed to upload avatar'; errorMessage.value = $t('settings-error-upload-avatar-failed-upload');
isSubmitting.value = false; isSubmitting.value = false;
} }
} }

View File

@@ -32,12 +32,15 @@ chat-invite-friend-too = Also send a friend request
chat-invite-send = Send chat-invite-send = Send
chat-invite-username-placeholder = username chat-invite-username-placeholder = username
chat-room-list-title = Rooms chat-room-list-title = Rooms
chat-room-list-connecting = Connecting...
chat-room-owner = by {$owner} chat-room-owner = by {$owner}
chat-create-title = Create room chat-create-title = Create room
chat-create-name = Room name chat-create-name = Room name
chat-create-name-placeholder = room name chat-create-name-placeholder = room name
chat-create-global = Global room chat-create-global = Global room
chat-create-submit = Create chat-create-submit = Create
chat-connecting = Connecting to room...
chat-connecting-failed = Could not connect. Check internet connection.
## Friends page ## Friends page
friends-title = Your friends friends-title = Your friends
@@ -82,6 +85,8 @@ settings-update-save = Save Changes
settings-updating = Updating... settings-updating = Updating...
settings-error-required = Username and Email are required. settings-error-required = Username and Email are required.
settings-error-failed = Update failed settings-error-failed = Update failed
settings-error-upload-avatar-failed-read = Failed to read image
settings-error-upload-avatar-failed-upload = Failed to upload image
## Warning ## Warning
warning-wrongversion-title = Wrong app version warning-wrongversion-title = Wrong app version

View File

@@ -32,12 +32,15 @@ chat-invite-friend-too = Envoyer aussi une demande d'ami
chat-invite-send = Envoyer chat-invite-send = Envoyer
chat-invite-username-placeholder = nom d'utilisateur chat-invite-username-placeholder = nom d'utilisateur
chat-room-list-title = Salons chat-room-list-title = Salons
chat-room-list-connecting = Connexion...
chat-room-owner = par {$owner} chat-room-owner = par {$owner}
chat-create-title = Créer un salon chat-create-title = Créer un salon
chat-create-name = Nom du salon chat-create-name = Nom du salon
chat-create-name-placeholder = nom du salon chat-create-name-placeholder = nom du salon
chat-create-global = Salon public chat-create-global = Salon public
chat-create-submit = Créer chat-create-submit = Créer
chat-connecting = Connexion au salon...
chat-connecting-failed = Impossible d'établir la connexion. Vérifiez votre internet.
## Friends page ## Friends page
friends-title = Vos amis friends-title = Vos amis
@@ -80,6 +83,8 @@ settings-upload-avatar-btn = Importer un avatar
settings-upload-avatar-title = Importer un avatar settings-upload-avatar-title = Importer un avatar
settings-error-required = Le nom d'utilisateur et l'email sont requis. settings-error-required = Le nom d'utilisateur et l'email sont requis.
settings-error-failed = Échec de la mise à jour settings-error-failed = Échec de la mise à jour
settings-error-upload-avatar-failed-read = Erreur de lecture de l'image
settings-error-upload-avatar-failed-upload = Erreur d'envoi de l'image
## Warning ## Warning
warning-wrongversion-title = Mauvaise version de l'application warning-wrongversion-title = Mauvaise version de l'application