From d9e2107fb89cd8202fe690578dba790e165b91f2 Mon Sep 17 00:00:00 2001 From: 21pages Date: Tue, 3 Mar 2026 21:38:46 +0800 Subject: [PATCH] refactor avatar display: unify rendering and resolve at use time - Extract buildAvatarWidget() in common.dart to share avatar rendering logic across desktop settings, desktop CM and mobile CM - Add resolve_avatar_url() in Rust, exposed via FFI (SyncReturn), to resolve relative avatar paths (e.g. "/avatar/xxx") to absolute URLs - Store avatar as-is in local config, only resolve when displaying (settings page) or sending (LoginRequest) - Resolve avatar in LoginRequest before sending to remote peer - Add error handling for network image load failures - Guard against empty client.name[0] crash - Show avatar in mobile settings page account tile Signed-off-by: 21pages --- flutter/lib/common.dart | 40 +++++++++++++++++++ .../desktop/pages/desktop_setting_page.dart | 32 +++++---------- flutter/lib/desktop/pages/server_page.dart | 40 ++++--------------- flutter/lib/mobile/pages/server_page.dart | 26 ++++-------- flutter/lib/mobile/pages/settings_page.dart | 10 ++++- flutter/lib/models/user_model.dart | 3 +- src/client.rs | 3 +- src/flutter_ffi.rs | 4 ++ src/hbbs_http/account.rs | 1 + src/ui_interface.rs | 39 +++++------------- 10 files changed, 90 insertions(+), 108 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index ab1b0b3c5..af87f980f 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -4118,3 +4118,43 @@ String mouseButtonsToPeer(int buttons) { return ''; } } + +/// Build an avatar widget from an avatar URL or data URI string. +/// Returns [fallback] if avatar is empty or cannot be decoded. +/// [borderRadius] defaults to [size]/2 (circle). +Widget? buildAvatarWidget({ + required String avatar, + required double size, + double? borderRadius, + Widget? fallback, +}) { + final trimmed = avatar.trim(); + if (trimmed.isEmpty) return fallback; + + ImageProvider? imageProvider; + if (trimmed.startsWith('data:image/')) { + final comma = trimmed.indexOf(','); + if (comma > 0) { + try { + imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1))); + } catch (_) {} + } + } else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + imageProvider = NetworkImage(trimmed); + } + + if (imageProvider == null) return fallback; + + final radius = borderRadius ?? size / 2; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Image( + image: imageProvider, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + fallback ?? SizedBox.shrink(), + ), + ); +} diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 36e1f8e9c..bde40cf19 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -2061,7 +2061,8 @@ class _AccountState extends State<_Account> { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, - color: Theme.of(context).textTheme.bodySmall?.color, + color: + Theme.of(context).textTheme.bodySmall?.color, ), ), ), @@ -2076,28 +2077,13 @@ class _AccountState extends State<_Account> { } Widget? _buildUserAvatar() { - final avatar = gFFI.userModel.avatar.value.trim(); - if (avatar.isEmpty) return null; - const radius = 22.0; - if (avatar.startsWith('data:image/')) { - final comma = avatar.indexOf(','); - if (comma > 0) { - try { - return CircleAvatar( - radius: radius, - backgroundImage: MemoryImage(base64Decode(avatar.substring(comma + 1))), - ); - } catch (_) { - return null; - } - } - } else if (avatar.startsWith('http://') || avatar.startsWith('https://')) { - return CircleAvatar( - radius: radius, - backgroundImage: NetworkImage(avatar), - ); - } - return null; + // Resolve relative avatar path at display time + final avatar = + bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 44, + ); } } diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 57bbdfabf..ea37c95e4 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -1,7 +1,6 @@ // original cm window in Sciter version. import 'dart:async'; -import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -569,37 +568,12 @@ class _CmHeaderState extends State<_CmHeader> bool get wantKeepAlive => true; Widget _buildClientAvatar() { - const borderRadius = BorderRadius.all(Radius.circular(15.0)); - final avatar = client.avatar.trim(); - if (avatar.startsWith('data:image/')) { - final comma = avatar.indexOf(','); - if (comma > 0) { - try { - final bytes = base64Decode(avatar.substring(comma + 1)); - return ClipRRect( - borderRadius: borderRadius, - child: Image.memory( - bytes, - width: 70, - height: 70, - fit: BoxFit.cover, - ), - ); - } catch (_) {} - } - } else if (avatar.startsWith('http://') || avatar.startsWith('https://')) { - return ClipRRect( - borderRadius: borderRadius, - child: Image.network( - avatar, - width: 70, - height: 70, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildInitialAvatar(), - ), - ); - } - return _buildInitialAvatar(); + return buildAvatarWidget( + avatar: client.avatar, + size: 70, + borderRadius: 15, + fallback: _buildInitialAvatar(), + )!; } Widget _buildInitialAvatar() { @@ -612,7 +586,7 @@ class _CmHeaderState extends State<_CmHeader> borderRadius: BorderRadius.circular(15.0), ), child: Text( - client.name[0], + client.name.isNotEmpty ? client.name[0] : '?', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.white, diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index feae68a32..d0a7b573e 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -857,28 +856,17 @@ class ClientInfo extends StatelessWidget { } Widget _buildAvatar(BuildContext context) { - final avatar = client.avatar.trim(); - if (avatar.isNotEmpty) { - if (avatar.startsWith('data:image/')) { - final comma = avatar.indexOf(','); - if (comma > 0) { - try { - return CircleAvatar( - backgroundImage: MemoryImage(base64Decode(avatar.substring(comma + 1))), - ); - } catch (_) {} - } - } else if (avatar.startsWith('http://') || avatar.startsWith('https://')) { - return CircleAvatar(backgroundImage: NetworkImage(avatar)); - } - } - // Show character as before if no avatar - return CircleAvatar( + final fallback = CircleAvatar( backgroundColor: str2color( client.name, Theme.of(context).brightness == Brightness.light ? 255 : 150), - child: Text(client.name[0]), + child: Text(client.name.isNotEmpty ? client.name[0] : '?'), ); + return buildAvatarWidget( + avatar: client.avatar, + size: 40, + fallback: fallback, + )!; } } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index afd3422d7..e047344ae 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -689,7 +689,15 @@ class _SettingsState extends State with WidgetsBindingObserver { title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty ? translate('Login') : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')), - leading: Icon(Icons.person), + leading: Obx(() { + final avatar = bind.mainResolveAvatarUrl( + avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 40, + ) ?? + Icon(Icons.person); + }), onPressed: (context) { if (gFFI.userModel.userName.value.isEmpty) { loginDialog(); diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index 3f0e1d463..cecb58eaa 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -34,6 +34,7 @@ class UserModel { } return '$preferred (@$username)'; } + WeakReference parent; UserModel(this.parent) { @@ -88,7 +89,6 @@ class UserModel { } final user = UserPayload.fromJson(data); - user.avatar = _resolveAvatar(user.avatar, url); _parseAndUpdateUser(user); } catch (e) { debugPrint('Failed to refreshCurrentUser: $e'); @@ -138,7 +138,6 @@ class UserModel { avatar.value = user.avatar; isAdmin.value = user.isAdmin; bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user)); - _updateLocalUserInfo(); if (isWeb) { // ugly here, tmp solution bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? ''); diff --git a/src/client.rs b/src/client.rs index cd9715619..8ea70898f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,7 +33,7 @@ use crate::{ create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, kcp_stream::KcpStream, secure_tcp, - ui_interface::{get_builtin_option, use_texture_render}, + ui_interface::{get_builtin_option, resolve_avatar_url, use_texture_render}, ui_session_interface::{InvokeUiSession, Session}, }; #[cfg(feature = "unix-file-copy-paste")] @@ -2638,6 +2638,7 @@ impl LoginConfigHandler { }) .unwrap_or_default(); } + avatar = resolve_avatar_url(avatar); let mut display_name = get_builtin_option(keys::OPTION_DISPLAY_NAME); if display_name.is_empty() { display_name = diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index ed13a7624..551ad799f 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1101,6 +1101,10 @@ pub fn main_get_api_server() -> String { get_api_server() } +pub fn main_resolve_avatar_url(avatar: String) -> SyncReturn { + SyncReturn(resolve_avatar_url(avatar)) +} + pub fn main_http_request(url: String, method: String, body: Option, header: String) { http_request(url, method, body, header) } diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 1c3d3b994..8e6141200 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -17,6 +17,7 @@ lazy_static::lazy_static! { const QUERY_INTERVAL_SECS: f32 = 1.0; const QUERY_TIMEOUT_SECS: u64 = 60 * 3; + const REQUESTING_ACCOUNT_AUTH: &str = "Requesting account auth"; const WAITING_ACCOUNT_AUTH: &str = "Waiting account auth"; const LOGIN_ACCOUNT_AUTH: &str = "Login account auth"; diff --git a/src/ui_interface.rs b/src/ui_interface.rs index dc1cda888..49098f2db 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -245,39 +245,20 @@ pub fn get_builtin_option(key: &str) -> String { #[inline] pub fn set_local_option(key: String, value: String) { - let value = normalize_local_option_value(&key, value); LocalConfig::set_option(key.clone(), value); } -fn normalize_local_option_value(key: &str, value: String) -> String { - if key != "user_info" || value.is_empty() { - return value; +/// Resolve relative avatar path (e.g. "/avatar/xxx") to absolute URL +/// by prepending the API server address. +pub fn resolve_avatar_url(avatar: String) -> String { + let avatar = avatar.trim().to_owned(); + if avatar.starts_with('/') { + let api_server = get_api_server(); + if !api_server.is_empty() { + return format!("{}{}", api_server.trim_end_matches('/'), avatar); + } } - let Ok(mut v) = serde_json::from_str::(&value) else { - return value; - }; - let Some(obj) = v.as_object_mut() else { - return value; - }; - let Some(avatar) = obj - .get("avatar") - .and_then(|x| x.as_str()) - .map(|x| x.trim().to_owned()) - else { - return value; - }; - if !avatar.starts_with('/') { - return value; - } - let api_server = get_api_server(); - if api_server.is_empty() { - return value; - } - obj.insert( - "avatar".to_owned(), - serde_json::Value::String(format!("{}{}", api_server.trim_end_matches('/'), avatar)), - ); - serde_json::to_string(&v).unwrap_or(value) + avatar } #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))]