fixed and improved profile pictures

This commit is contained in:
2026-01-13 17:32:39 +01:00
parent 68116e7353
commit 9e6f8630fa
17 changed files with 222 additions and 144 deletions

View File

@@ -13,6 +13,7 @@
"@fluent/bundle": "^0.19.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "~2",

1
src-tauri/Cargo.lock generated
View File

@@ -444,6 +444,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-opener",
"tauri-plugin-store",

View File

@@ -27,4 +27,5 @@ tauri-plugin-http = "2"
tauri-plugin-websocket = "2"
tauri-plugin-upload = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"

View File

@@ -15,6 +15,9 @@
{
"url": "http://127.0.0.1:*"
},
{
"url": "http://192.168.1.183:*"
},
{
"url": "https://alatreon.org/chatapp/*"
}
@@ -27,6 +30,9 @@
{
"url": "ws://127.0.0.1:*"
},
{
"url": "http://192.168.1.183:*"
},
{
"url": "wss://alatreon.org/chatapp/*"
}
@@ -34,7 +40,14 @@
},
"websocket:default",
"upload:default",
"dialog:default"
"dialog:default",
"fs:default",
{
"identifier": "fs:scope",
"allow": [
"*"
]
}
]
}

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />

View File

@@ -7,6 +7,7 @@ fn greet(name: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_websocket::init())

View File

@@ -1,7 +1,7 @@
import { UpdateUserResponse } from '../types';
import { apiFetch } from './client'
import { upload } from '@tauri-apps/plugin-upload';
import { getAuthData } from '../authStore';
// import { upload } from '@tauri-apps/plugin-upload';
import { getAuthData } from '../store';
import { API } from '../main.ts';
export function updateSettings(username: string, email: string, password: string) {
@@ -12,24 +12,41 @@ export function updateSettings(username: string, email: string, password: string
}
export async function uploadAvatar(
filePath: string,
fileData: Uint8Array,
onProgress: (progress: number, total: number) => void
) {
const auth = await getAuthData();
const url = `${API}/account/upload-avatar`;
const headers = new Map<string, string>([
['Authorization', `Bearer ${auth.token}`],
['Content-Type', 'application/octet-stream']
]);
return upload(
url,
filePath,
({ progress, total }) => {
onProgress(progress, total);
},
headers
);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
// Handle Progress
if (xhr.upload && onProgress) {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(event.loaded, event.total);
}
};
}
// Handle Response
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error('Network error during upload'));
xhr.send(fileData);
});
}
export function getAvatar(uuid: string): string {

View File

@@ -1,5 +1,5 @@
import { fetch } from '@tauri-apps/plugin-http';
import { getAuthData, clearAuthData } from '../authStore'
import { getAuthData, clearAuthData } from '../store'
import { API } from '../main.ts'
import router from '../router'

View File

@@ -1,78 +0,0 @@
import { load, Store } from '@tauri-apps/plugin-store'
import { UpdateUserResponse, User } from './types'
import { getAvatar } from './api/account'
let store: Store | null = null
async function getStore() {
if (!store) store = await load('store.json')
return store
}
export async function getAuthData() {
const s = await getStore()
const token = await s.get<string>('token')
const user = await s.get<User>('user')
return { token: token || null, user: user || null, isAuthenticated: !!token }
}
export async function saveAuthData(token: string, user: User) {
const s = await getStore()
await s.set('token', token)
await s.set('user', user)
await s.save()
}
export async function clearAuthData() {
const s = await getStore()
s.clear()
await s.save()
}
export async function updateLocalUser(newData: UpdateUserResponse, uuid?: string) {
const updatedUser = {
username: newData.username,
email: newData.email,
uuid,
avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}`
}
const s = await getStore()
await s.set('user', updatedUser)
await s.save()
}
export async function refreshLocalUser() {
const s = await getStore()
const user = await s.get<User>('user')
if (user) {
user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}`
await s.set('user', user)
await s.save()
}
}
export async function setLastRoom(uuid: string) {
if (!uuid || uuid === 'none') return
const s = await getStore()
await s.set('last_room_uuid', uuid)
await s.save()
}
export async function getLastRoom(): Promise<string | null> {
const s = await getStore()
return (await s.get<string>('last_room_uuid')) ?? null
}
export async function saveLocalePreference(locale: string) {
const s = await getStore()
await s.set('language', locale)
await s.save()
}
export async function getLocalePreference(): Promise<string | null> {
const s = await getStore()
return (await s.get<string>('language')) ?? null
}

View File

@@ -35,7 +35,7 @@ import MessageList from "./MessageList.vue";
import MessageInput from "./MessageInput.vue";
import InvitePeopleModal from './InvitePeopleModal.vue';
import WebSocket from '@tauri-apps/plugin-websocket';
import { getAuthData } from "../authStore.ts";
import { getAuthData } from "../store.ts";
import { fetchRoomInfo } from "../api/rooms.ts";
const props = defineProps<{ uuid: string }>();

View File

@@ -2,7 +2,7 @@
<ul>
<li v-for="(m, i) in messages" :key="i" class="message" :class="{ 'is-me': m.sender_uuid === currentUserUuid }">
<div class="sender-info">
<img :src="getAvatar(m.sender_uuid)" @error="handleAvatarError" class="sender-avatar" />
<img :src="getAvatarUrl(m.sender_uuid)" @error="handleAvatarError" class="sender-avatar" />
<div class="sender">{{ m.sender }}</div>
<span class="timestamp">{{ m.sent_at }}</span>
</div>
@@ -11,13 +11,12 @@
</ul>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { Message } from '../types'
import { getAvatar } from '../api/account.ts'
import { getAvatarUrl } from '../store.ts'
import defaultAvatar from '../assets/default-avatar.png'
import { getAuthData } from '../authStore';
import { getAuthData } from '../store.ts';
defineProps<{ messages: Message[] }>()
@@ -72,7 +71,7 @@ ul {
/* border-radius: var(--radius) var(--radius) 0 0; */
/* background-color: rgba(255, 255, 255, 0.02); */
width: 100%;
padding: 5px 10px;
padding: 5px 18px;
}
.sender-avatar {

View File

@@ -38,7 +38,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { updateSettings } from '../api/account'
import { updateLocalUser } from '../authStore'
import { updateLocalUser } from '../store'
import type { User } from '../types'
import { useFluent } from 'fluent-vue';

View File

@@ -41,8 +41,11 @@ import { ref, onMounted, onUnmounted } from 'vue';
import { uploadAvatar } from '../api/account';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import { convertFileSrc } from '@tauri-apps/api/core';
import { refreshLocalUser } from '../authStore';
// import { convertFileSrc } from '@tauri-apps/api/core';
import { readFile } from '@tauri-apps/plugin-fs';
import { refreshLocalUser } from '../store.ts';
import { getAuthData } from '../store.ts';
import { refreshAvatar } from '../store.ts';
const emit = defineEmits(['close', 'updated']);
@@ -59,24 +62,13 @@ let unlistenHover: UnlistenFn;
let unlistenLeave: UnlistenFn;
onMounted(async () => {
// When files are hovered over the window
unlistenHover = await listen('tauri://drag-enter', () => {
isDragging.value = true;
});
unlistenLeave = await listen('tauri://drag-leave', () => {
isDragging.value = false;
});
// When files are dropped
unlistenHover = await listen('tauri://drag-enter', () => isDragging.value = true);
unlistenLeave = await listen('tauri://drag-leave', () => isDragging.value = false);
unlistenDrag = await listen<{ paths: string[] }>('tauri://drag-drop', (event) => {
isDragging.value = false;
const path = event.payload.paths[0];
if (path && isImage(path)) {
setFile(path);
} else {
errorMessage.value = "Please drop a valid image file.";
}
if (path && isImage(path)) setFile(path);
else errorMessage.value = "Please drop a valid image file.";
});
});
@@ -84,6 +76,8 @@ onUnmounted(() => {
if (unlistenDrag) unlistenDrag();
if (unlistenHover) unlistenHover();
if (unlistenLeave) unlistenLeave();
// Clean up
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
});
async function pickFile() {
@@ -94,18 +88,30 @@ async function pickFile() {
});
if (selected && typeof selected === 'string') {
setFile(selected);
await setFile(selected);
}
} catch (err) {
console.error(err);
}
}
// Utility to set path and preview
function setFile(path: string) {
selectedPath.value = path;
previewUrl.value = convertFileSrc(path);
errorMessage.value = '';
// Async function to read file and create blob URL
async function setFile(path: string) {
try {
selectedPath.value = path;
const contents = await readFile(path);
const blob = new Blob([contents]);
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = URL.createObjectURL(blob);
errorMessage.value = '';
} catch (e) {
console.error("Error reading file:", e);
errorMessage.value = "Could not read image file.";
}
}
function isImage(path: string) {
@@ -120,17 +126,25 @@ async function handleUpload() {
uploadProgress.value = 0;
try {
await uploadAvatar(selectedPath.value, (progress, total) => {
const fileBytes = await readFile(selectedPath.value);
await uploadAvatar(fileBytes, (progress, total) => {
uploadProgress.value = Math.round((progress / total) * 100);
});
await refreshLocalUser();
// Trigger global UI refresh
const auth = await getAuthData();
if (auth.user) {
refreshAvatar(auth.user.uuid);
}
emit('updated');
emit('close');
} catch (err: any) {
console.error("Upload failed:", err);
errorMessage.value = 'Failed to upload avatar. Please try again.';
errorMessage.value = err.message || 'Failed to upload avatar';
isSubmitting.value = false;
}
}
@@ -254,6 +268,8 @@ async function handleUpload() {
.error-message {
color: var(--error);
font-size: 0.9rem;
word-break: break-all;
overflow-wrap: anywhere;
}
.actions {

View File

@@ -5,7 +5,7 @@ import { validateToken } from './store.ts'
import { fluent, setLanguage } from './i18n'
import './base.css'
import { getLocalePreference } from './authStore.ts'
import { getLocalePreference } from './store.ts'
async function init() {
await validateToken()
@@ -28,7 +28,9 @@ async function init() {
init()
export const API = 'http://127.0.0.1:8080'
// export const API = 'http://127.0.0.1:8080'
export const API = 'http://192.168.1.183:8080'
// export const API = 'https://alatreon.org/chatapp'
export const API_WS = 'ws://127.0.0.1:8080/ws'
// export const API_WS = 'ws://127.0.0.1:8080/ws'
export const API_WS = 'ws://192.168.1.183:8080/ws'
// export const API_WS = 'wss://alatreon.org/chatapp/ws'

View File

@@ -3,12 +3,12 @@
<h1>{{ $t('settings-title') }}</h1>
<UpdateAccountModal v-if="showUpdateModal" :user="user" @close="showUpdateModal = false" @updated="fetchUserData" />
<UploadAvatarModal v-if="showAvatarModal" @close="showAvatarModal = false" @updated="fetchUserData" />
<UploadAvatarModal v-if="showAvatarModal" @close="showAvatarModal = false" />
<h2>{{ $t('settings-account') }}</h2>
<div v-if="user" class="info-card">
<div class="avatar-display">
<img :src="getAvatar(user.uuid)" @error="handleAvatarError" class="avatar-img" />
<img :src="getAvatarUrl(user.uuid)" @error="handleAvatarError" class="avatar-img" />
<button class="update-btn" @click="showAvatarModal = true">
{{ $t('settings-upload-avatar-btn') || 'Change Avatar' }}
@@ -49,21 +49,25 @@
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { logout as authLogout } from '../store.ts'
import { getAuthData } from "../authStore.ts"
import { getAuthData } from "../store.ts"
import type { User } from "../types"
import UpdateAccountModal from '../components/UpdateAccountModal.vue'
import { useFluent } from 'fluent-vue'
import { saveLocalePreference, getLocalePreference } from "../authStore.ts"
import { saveLocalePreference, getLocalePreference } from "../store.ts"
import { getSupportedLanguagesMetadata, setLanguage } from '../i18n'
import UploadAvatarModal from '../components/UploadAvatarModal.vue'
import defaultAvatar from '../assets/default-avatar.png'
import { getAvatar } from '../api/account.ts'
import { getAvatarUrl } from '../store.ts'
const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement;
img.src = defaultAvatar;
};
async function handleAvatarUpdated() {
await fetchUserData()
}
const showAvatarModal = ref(false)
const router = useRouter()

View File

@@ -1,15 +1,90 @@
import { apiFetch } from './api/client'
import type { LoginResponse, User } from './types'
import * as authStore from './authStore'
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'
let store: Store | null = null
export const initAuth = getAuthData
async function getStore() {
if (!store) store = await load('store.json')
return store
}
export async function getAuthData() {
const s = await getStore()
const token = await s.get<string>('token')
const user = await s.get<User>('user')
return { token: token || null, user: user || null, isAuthenticated: !!token }
}
export async function saveAuthData(token: string, user: User) {
const s = await getStore()
await s.set('token', token)
await s.set('user', user)
await s.save()
}
export async function clearAuthData() {
const s = await getStore()
s.clear()
await s.save()
}
export async function updateLocalUser(newData: UpdateUserResponse, uuid?: string) {
const updatedUser = {
username: newData.username,
email: newData.email,
uuid,
avatar_url: `${getAvatar(uuid || '')}?t=${Date.now()}`
}
const s = await getStore()
await s.set('user', updatedUser)
await s.save()
}
export async function refreshLocalUser() {
const s = await getStore()
const user = await s.get<User>('user')
if (user) {
user.avatar_url = `${getAvatar(user.uuid)}?t=${Date.now()}`
await s.set('user', user)
await s.save()
}
}
export async function setLastRoom(uuid: string) {
if (!uuid || uuid === 'none') return
const s = await getStore()
await s.set('last_room_uuid', uuid)
await s.save()
}
export async function getLastRoom(): Promise<string | null> {
const s = await getStore()
return (await s.get<string>('last_room_uuid')) ?? null
}
export async function saveLocalePreference(locale: string) {
const s = await getStore()
await s.set('language', locale)
await s.save()
}
export async function getLocalePreference(): Promise<string | null> {
const s = await getStore()
return (await s.get<string>('language')) ?? null
}
export const initAuth = authStore.getAuthData
export const getLastRoom = authStore.getLastRoom
export const setLastRoom = authStore.setLastRoom
export async function login(email: string, username: string, password: string) {
const res: LoginResponse = await apiFetch('/login', {
@@ -21,19 +96,19 @@ export async function login(email: string, username: string, password: string) {
uuid: res.uuid,
username: res.username,
email: res.email,
avatar_url: await getAvatar(res.uuid)
avatar_url: getAvatar(res.uuid)
};
await authStore.saveAuthData(res.token, user)
await saveAuthData(res.token, user)
return { token: res.token, uuid: res.uuid, isAuthenticated: true }
}
export async function logout() {
await authStore.clearAuthData()
await clearAuthData()
return { token: null, uuid: null, isAuthenticated: false }
}
export async function validateToken(): Promise<boolean> {
const auth = await authStore.getAuthData()
const auth = await getAuthData()
if (!auth.token) return false
try {
@@ -62,7 +137,7 @@ export function useNotifications() {
const totalCount = computed(() => requests.value.length + invites.value.length)
async function refreshNotifications() {
const auth = await authStore.getAuthData()
const auth = await getAuthData()
if (!auth.token) {
return
}
@@ -86,3 +161,21 @@ export function useNotifications() {
refreshNotifications
}
}
// A reactive object to store the last updated timestamp for each user
const avatarTimestamps = reactive<Record<string, number>>({})
// Generates the avatar URL with a timestamp
export function getAvatarUrl(uuid: string | undefined | null) {
if (!uuid) return ''
if (!avatarTimestamps[uuid]) {
avatarTimestamps[uuid] = Date.now()
}
return `${getAvatar(uuid)}?t=${avatarTimestamps[uuid]}`
}
export function refreshAvatar(uuid: string) {
avatarTimestamps[uuid] = Date.now()
}

View File

@@ -409,6 +409,13 @@
dependencies:
"@tauri-apps/api" "^2.8.0"
"@tauri-apps/plugin-fs@~2":
version "2.4.5"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz#4b32b6de32e2ee735632bff356fab09fcc281b42"
integrity sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==
dependencies:
"@tauri-apps/api" "^2.8.0"
"@tauri-apps/plugin-http@~2":
version "2.5.4"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-http/-/plugin-http-2.5.4.tgz#998a9cd02efa006fcbeddd92e8e51434ff3804dd"