improved error handling and added retry buttons in chat page
This commit is contained in:
@@ -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') {
|
||||||
const data: Message = JSON.parse(msg.data);
|
try {
|
||||||
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
const data: Message = JSON.parse(msg.data);
|
||||||
messages.value.push(data);
|
|
||||||
nextTick().then(scrollToBottomIfAtEnd);
|
// 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);
|
||||||
|
}
|
||||||
|
} 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
|
||||||
initializeRoom();
|
watch(() => props.uuid, (newUuid, oldUuid) => {
|
||||||
|
if (newUuid !== oldUuid) {
|
||||||
|
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%;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user