Isovel/contacts sorting (#26)

* feat: update contacts list sorting + correctly handle headless hosts
---------

Co-authored-by: Garrett Watson <toast@isota.ch>
This commit is contained in:
Nutcake 2024-01-27 15:55:55 +01:00 committed by GitHub
parent 8b285203aa
commit 76e32887e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 183 additions and 78 deletions

View file

@ -1,5 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:recon/apis/contact_api.dart'; import 'package:recon/apis/contact_api.dart';
import 'package:recon/apis/message_api.dart'; import 'package:recon/apis/message_api.dart';
import 'package:recon/apis/session_api.dart'; import 'package:recon/apis/session_api.dart';
@ -15,12 +20,6 @@ import 'package:recon/models/session.dart';
import 'package:recon/models/users/friend.dart'; import 'package:recon/models/users/friend.dart';
import 'package:recon/models/users/online_status.dart'; import 'package:recon/models/users/online_status.dart';
import 'package:recon/models/users/user_status.dart'; import 'package:recon/models/users/user_status.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
class MessagingClient extends ChangeNotifier { class MessagingClient extends ChangeNotifier {
static const Duration _autoRefreshDuration = Duration(seconds: 10); static const Duration _autoRefreshDuration = Duration(seconds: 10);
@ -49,11 +48,13 @@ class MessagingClient extends ChangeNotifier {
UserStatus get userStatus => _userStatus; UserStatus get userStatus => _userStatus;
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient, required SettingsClient settingsClient}) MessagingClient(
{required ApiClient apiClient,
required NotificationClient notificationClient,
required SettingsClient settingsClient})
: _apiClient = apiClient, : _apiClient = apiClient,
_notificationClient = notificationClient, _notificationClient = notificationClient,
_settingsClient = settingsClient _settingsClient = settingsClient {
{
debugPrint("mClient created: $hashCode"); debugPrint("mClient created: $hashCode");
Hive.openBox(_messageBoxKey).then((box) async { Hive.openBox(_messageBoxKey).then((box) async {
await box.delete(_lastUpdateKey); await box.delete(_lastUpdateKey);
@ -222,14 +223,44 @@ class MessagingClient extends ChangeNotifier {
} catch (_) {} } catch (_) {}
} }
// Calculate online status value, with 'headless' between 'busy' and 'offline'
double getOnlineStatusValue(Friend friend) {
// Adjusting values to ensure correct placement of 'headless'
if (friend.isHeadless) return 2.5;
switch (friend.userStatus.onlineStatus) {
case OnlineStatus.online:
return 0;
case OnlineStatus.away:
return 1;
case OnlineStatus.busy:
return 2;
case OnlineStatus.invisible:
return 2.5;
case OnlineStatus.offline:
default:
return 3;
}
}
void _sortFriendsCache() { void _sortFriendsCache() {
_sortedFriendsCache.sort((a, b) { _sortedFriendsCache.sort((a, b) {
var aVal = friendHasUnreads(a) ? -3 : 0; // Check for unreads and sort by latest message time if either has unreads
var bVal = friendHasUnreads(b) ? -3 : 0; bool aHasUnreads = friendHasUnreads(a);
bool bHasUnreads = friendHasUnreads(b);
if (aHasUnreads || bHasUnreads) {
if (aHasUnreads && bHasUnreads) {
return -a.latestMessageTime.compareTo(b.latestMessageTime);
}
aVal -= a.latestMessageTime.compareTo(b.latestMessageTime); return aHasUnreads ? -1 : 1;
aVal += a.userStatus.onlineStatus.compareTo(b.userStatus.onlineStatus) * 2; }
return aVal.compareTo(bVal);
int onlineStatusComparison = getOnlineStatusValue(a).compareTo(getOnlineStatusValue(b));
if (onlineStatusComparison != 0) {
return onlineStatusComparison;
}
return -a.latestMessageTime.compareTo(b.latestMessageTime);
}); });
} }
@ -280,7 +311,8 @@ class MessagingClient extends ChangeNotifier {
await _refreshUnreads(); await _refreshUnreads();
_unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads()); _unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads());
_hubManager.send("RequestStatus", arguments: [null, false]); _hubManager.send("RequestStatus", arguments: [null, false]);
final lastOnline = OnlineStatus.values.elementAtOrNull(_settingsClient.currentSettings.lastOnlineStatus.valueOrDefault); final lastOnline =
OnlineStatus.values.elementAtOrNull(_settingsClient.currentSettings.lastOnlineStatus.valueOrDefault);
await setOnlineStatus(lastOnline ?? OnlineStatus.online); await setOnlineStatus(lastOnline ?? OnlineStatus.online);
_statusHeartbeat = Timer.periodic(_statusHeartbeatDuration, (timer) { _statusHeartbeat = Timer.periodic(_statusHeartbeatDuration, (timer) {
setOnlineStatus(_userStatus.onlineStatus); setOnlineStatus(_userStatus.onlineStatus);
@ -332,7 +364,9 @@ class MessagingClient extends ChangeNotifier {
var status = UserStatus.fromMap(statusUpdate); var status = UserStatus.fromMap(statusUpdate);
final sessionMap = createSessionMap(status.hashSalt); final sessionMap = createSessionMap(status.hashSalt);
status = status.copyWith( status = status.copyWith(
decodedSessions: status.sessions.map((e) => sessionMap[e.sessionHash] ?? Session.none().copyWith(accessLevel: e.accessLevel)).toList()); decodedSessions: status.sessions
.map((e) => sessionMap[e.sessionHash] ?? Session.none().copyWith(accessLevel: e.accessLevel))
.toList());
final friend = getAsFriend(statusUpdate["userId"])?.copyWith(userStatus: status); final friend = getAsFriend(statusUpdate["userId"])?.copyWith(userStatus: status);
if (friend != null) { if (friend != null) {
_updateContact(friend); _updateContact(friend);

View file

@ -1,7 +1,7 @@
import 'package:recon/auxiliary.dart'; import 'package:recon/auxiliary.dart';
import 'package:recon/models/users/user_profile.dart';
import 'package:recon/models/users/friend_status.dart'; import 'package:recon/models/users/friend_status.dart';
import 'package:recon/models/users/online_status.dart'; import 'package:recon/models/users/online_status.dart';
import 'package:recon/models/users/user_profile.dart';
import 'package:recon/models/users/user_status.dart'; import 'package:recon/models/users/user_status.dart';
class Friend implements Comparable { class Friend implements Comparable {
@ -15,11 +15,26 @@ class Friend implements Comparable {
final FriendStatus contactStatus; final FriendStatus contactStatus;
final DateTime latestMessageTime; final DateTime latestMessageTime;
const Friend({required this.id, required this.username, required this.ownerId, required this.userStatus, required this.userProfile, const Friend({
required this.contactStatus, required this.latestMessageTime, required this.id,
required this.username,
required this.ownerId,
required this.userStatus,
required this.userProfile,
required this.contactStatus,
required this.latestMessageTime,
}); });
bool get isHeadless => userStatus.outputDevice == "Headless"; bool get isHeadless => userStatus.sessionType == UserSessionType.headless;
bool get isBot => userStatus.sessionType == UserSessionType.bot || id == _resoniteBotId;
bool get isOffline =>
(userStatus.onlineStatus == OnlineStatus.offline || userStatus.onlineStatus == OnlineStatus.invisible) &&
!isBot &&
!isHeadless;
bool get isOnline => !isOffline;
factory Friend.fromMap(Map map) { factory Friend.fromMap(Map map) {
var userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]); var userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]);
@ -27,12 +42,13 @@ class Friend implements Comparable {
id: map["id"], id: map["id"],
username: map["contactUsername"], username: map["contactUsername"],
ownerId: map["ownerId"] ?? map["id"], ownerId: map["ownerId"] ?? map["id"],
// Neos bot status is always offline but should be displayed as online // Resonite bot status is always offline but should be displayed as online
userStatus: map["id"] == _resoniteBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus, userStatus: map["id"] == _resoniteBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus,
userProfile: UserProfile.fromMap(map["profile"] ?? {}), userProfile: UserProfile.fromMap(map["profile"] ?? {}),
contactStatus: FriendStatus.fromString(map["contactStatus"]), contactStatus: FriendStatus.fromString(map["contactStatus"]),
latestMessageTime: map["latestMessageTime"] == null latestMessageTime: map["latestMessageTime"] == null
? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(map["latestMessageTime"]), ? DateTime.fromMillisecondsSinceEpoch(0)
: DateTime.parse(map["latestMessageTime"]),
); );
} }
@ -43,21 +59,26 @@ class Friend implements Comparable {
factory Friend.empty() { factory Friend.empty() {
return Friend( return Friend(
id: _emptyId, id: _emptyId,
username: "", username: "",
ownerId: "", ownerId: "",
userStatus: UserStatus.empty(), userStatus: UserStatus.empty(),
userProfile: UserProfile.empty(), userProfile: UserProfile.empty(),
contactStatus: FriendStatus.none, contactStatus: FriendStatus.none,
latestMessageTime: DateTimeX.epoch latestMessageTime: DateTimeX.epoch,
); );
} }
bool get isEmpty => id == _emptyId; bool get isEmpty => id == _emptyId;
Friend copyWith({ Friend copyWith(
String? id, String? username, String? ownerId, UserStatus? userStatus, UserProfile? userProfile, {String? id,
FriendStatus? contactStatus, DateTime? latestMessageTime}) { String? username,
String? ownerId,
UserStatus? userStatus,
UserProfile? userProfile,
FriendStatus? contactStatus,
DateTime? latestMessageTime}) {
return Friend( return Friend(
id: id ?? this.id, id: id ?? this.id,
username: username ?? this.username, username: username ?? this.username,
@ -69,7 +90,7 @@ class Friend implements Comparable {
); );
} }
Map toMap({bool shallow=false}) { Map toMap({bool shallow = false}) {
return { return {
"id": id, "id": id,
"contactUsername": username, "contactUsername": username,

View file

@ -15,10 +15,13 @@ enum OnlineStatus {
Colors.green, Colors.green,
]; ];
Color color(BuildContext context) => this == OnlineStatus.offline || this == OnlineStatus.invisible ? Theme.of(context).colorScheme.onSurface : _colors[index]; Color color(BuildContext context) => this == OnlineStatus.offline || this == OnlineStatus.invisible
? Theme.of(context).colorScheme.onSecondaryContainer.withAlpha(150)
: _colors[index];
factory OnlineStatus.fromString(String? text) { factory OnlineStatus.fromString(String? text) {
return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), return OnlineStatus.values.firstWhere(
(element) => element.name.toLowerCase() == text?.toLowerCase(),
orElse: () => OnlineStatus.online, orElse: () => OnlineStatus.online,
); );
} }

View file

@ -54,13 +54,12 @@ class FriendListTile extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
FriendOnlineStatusIndicator(userStatus: friend.userStatus), FriendOnlineStatusIndicator(friend: friend),
const SizedBox( const SizedBox(
width: 4, width: 4,
), ),
Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"), if (!(friend.isOffline || friend.isHeadless)) ...[
if (!(friend.userStatus.onlineStatus == OnlineStatus.offline || Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"),
friend.userStatus.onlineStatus == OnlineStatus.invisible))
if (currentSession != null) ...[ if (currentSession != null) ...[
const Text(" in "), const Text(" in "),
if (currentSession.name.isNotEmpty) if (currentSession.name.isNotEmpty)
@ -87,6 +86,24 @@ class FriendListTile extends StatelessWidget {
maxLines: 1, maxLines: 1,
), ),
), ),
] else if (friend.isOffline)
Text(
"Offline",
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: theme.textTheme.bodyMedium?.copyWith(
color: OnlineStatus.offline.color(context),
),
)
else
Text(
"Headless Host",
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color.fromARGB(255, 41, 77, 92),
),
)
], ],
), ),
onTap: () async { onTap: () async {

View file

@ -1,27 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:recon/models/users/friend.dart';
import 'package:recon/models/users/online_status.dart'; import 'package:recon/models/users/online_status.dart';
import 'package:recon/models/users/user_status.dart'; import 'package:recon/models/users/user_status.dart';
class FriendOnlineStatusIndicator extends StatelessWidget { class FriendOnlineStatusIndicator extends StatelessWidget {
const FriendOnlineStatusIndicator({required this.userStatus, super.key}); const FriendOnlineStatusIndicator({required this.friend, super.key});
final UserStatus userStatus; final Friend friend;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return userStatus.appVersion.contains("ReCon") && userStatus.onlineStatus != OnlineStatus.offline final UserStatus userStatus = friend.userStatus;
final OnlineStatus onlineStatus = userStatus.onlineStatus;
return userStatus.appVersion.contains("ReCon") && friend.isOnline
? SizedBox.square( ? SizedBox.square(
dimension: 10, dimension: 10,
child: Image.asset( child: Image.asset(
"assets/images/logo-white.png", "assets/images/logo-white.png",
color: userStatus.onlineStatus.color(context), color: onlineStatus.color(context),
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
isAntiAlias: true, isAntiAlias: true,
), ),
) )
: Icon( : Icon(
userStatus.onlineStatus == OnlineStatus.offline ? Icons.circle_outlined : Icons.circle, friend.isOffline ? Icons.circle_outlined : Icons.circle,
color: userStatus.onlineStatus.color(context), color: friend.isHeadless ? const Color.fromARGB(255, 41, 77, 92) : onlineStatus.color(context),
size: 10, size: 10,
); );
} }

View file

@ -62,7 +62,7 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
title: Row( title: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
FriendOnlineStatusIndicator(userStatus: friend.userStatus), FriendOnlineStatusIndicator(friend: friend),
const SizedBox( const SizedBox(
width: 8, width: 8,
), ),

View file

@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:recon/apis/user_api.dart'; import 'package:recon/apis/user_api.dart';
import 'package:recon/auxiliary.dart'; import 'package:recon/auxiliary.dart';
import 'package:recon/client_holder.dart'; import 'package:recon/client_holder.dart';
import 'package:recon/models/personal_profile.dart'; import 'package:recon/models/personal_profile.dart';
import 'package:recon/widgets/default_error_widget.dart'; import 'package:recon/widgets/default_error_widget.dart';
import 'package:recon/widgets/generic_avatar.dart'; import 'package:recon/widgets/generic_avatar.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class MyProfileDialog extends StatefulWidget { class MyProfileDialog extends StatefulWidget {
const MyProfileDialog({super.key}); const MyProfileDialog({super.key});
@ -19,7 +19,6 @@ class _MyProfileDialogState extends State<MyProfileDialog> {
Future<PersonalProfile>? _personalProfileFuture; Future<PersonalProfile>? _personalProfileFuture;
Future<StorageQuota>? _storageQuotaFuture; Future<StorageQuota>? _storageQuotaFuture;
@override @override
void didChangeDependencies() async { void didChangeDependencies() async {
super.didChangeDependencies(); super.didChangeDependencies();
@ -34,9 +33,7 @@ class _MyProfileDialogState extends State<MyProfileDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tt = Theme final tt = Theme.of(context).textTheme;
.of(context)
.textTheme;
DateFormat dateFormat = DateFormat.yMd(); DateFormat dateFormat = DateFormat.yMd();
return Dialog( return Dialog(
child: FutureBuilder( child: FutureBuilder(
@ -58,50 +55,79 @@ class _MyProfileDialogState extends State<MyProfileDialog> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(profile.username, style: tt.titleLarge), Text(profile.username, style: tt.titleLarge),
Text(profile.email, style: tt.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withAlpha(150)),) Text(
profile.email,
style:
tt.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withAlpha(150)),
)
], ],
), ),
GenericAvatar(imageUri: Aux.resdbToHttp(profile.userProfile.iconUrl), radius: 24,) GenericAvatar(
imageUri: Aux.resdbToHttp(profile.userProfile.iconUrl),
radius: 24,
)
], ],
), ),
const SizedBox(height: 16,), const SizedBox(
Row( height: 16,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text("User ID: ", style: tt.labelLarge,), Text(profile.id)],
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text("2FA: ", style: tt.labelLarge,), Text(
"User ID: ",
style: tt.labelLarge,
),
Text(profile.id)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"2FA: ",
style: tt.labelLarge,
),
Text(profile.twoFactor ? "Enabled" : "Disabled") Text(profile.twoFactor ? "Enabled" : "Disabled")
], ],
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text("Patreon Supporter: ", style: tt.labelLarge,), Text(
"Patreon Supporter: ",
style: tt.labelLarge,
),
Text(profile.isPatreonSupporter ? "Yes" : "No") Text(profile.isPatreonSupporter ? "Yes" : "No")
], ],
), ),
if (profile.publicBanExpiration?.isAfter(DateTime.now()) ?? false) if (profile.publicBanExpiration?.isAfter(DateTime.now()) ?? false)
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text("Ban Expiration: ", style: tt.labelLarge,), children: [
Text(dateFormat.format(profile.publicBanExpiration!))], Text(
"Ban Expiration: ",
style: tt.labelLarge,
),
Text(dateFormat.format(profile.publicBanExpiration!))
],
), ),
FutureBuilder( FutureBuilder(
future: _storageQuotaFuture, future: _storageQuotaFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
final storage = snapshot.data; final storage = snapshot.data;
return StorageIndicator(usedBytes: storage?.usedBytes ?? 0, maxBytes: storage?.fullQuotaBytes ?? 1,); return StorageIndicator(
} usedBytes: storage?.usedBytes ?? 0,
maxBytes: storage?.fullQuotaBytes ?? 1,
);
}),
const SizedBox(
height: 12,
), ),
const SizedBox(height: 12,),
], ],
), ),
); );
} } else if (snapshot.hasError) {
else if (snapshot.hasError) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -109,9 +135,7 @@ class _MyProfileDialogState extends State<MyProfileDialog> {
message: snapshot.error.toString(), message: snapshot.error.toString(),
onRetry: () { onRetry: () {
setState(() { setState(() {
_personalProfileFuture = UserApi.getPersonalProfile(ClientHolder _personalProfileFuture = UserApi.getPersonalProfile(ClientHolder.of(context).apiClient);
.of(context)
.apiClient);
}); });
}, },
), ),
@ -142,7 +166,7 @@ class StorageIndicator extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final value = usedBytes/maxBytes; final value = usedBytes / maxBytes;
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Column( child: Column(
@ -152,10 +176,13 @@ class StorageIndicator extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text("Storage:", style: Theme.of(context).textTheme.titleMedium), Text("Storage:", style: Theme.of(context).textTheme.titleMedium),
Text("${(usedBytes * 1e-9).toStringAsFixed(2)}/${(maxBytes * 1e-9).toStringAsFixed(2)} GiB"), Text(// Displayed in GiB instead of GB for consistency with Resonite
"${(usedBytes * 9.3132257461548e-10).toStringAsFixed(2)}/${(maxBytes * 9.3132257461548e-10).toStringAsFixed(2)} GB"),
], ],
), ),
const SizedBox(height: 8,), const SizedBox(
height: 8,
),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator( child: LinearProgressIndicator(
@ -168,4 +195,4 @@ class StorageIndicator extends StatelessWidget {
), ),
); );
} }
} }