Fix contact status loading

This commit is contained in:
Nutcake 2023-09-29 15:33:43 +02:00
parent f305fcd23c
commit b1f2d65ab8
8 changed files with 262 additions and 192 deletions

View file

@ -22,7 +22,7 @@ class ContactApi {
ownerId: client.userId,
userStatus: UserStatus.empty(),
userProfile: UserProfile.empty(),
friendStatus: FriendStatus.accepted,
contactStatus: FriendStatus.accepted,
latestMessageTime: DateTime.now(),
);
final body = jsonEncode(friend.toMap(shallow: true));

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:contacts_plus_plus/hub_manager.dart';
import 'package:contacts_plus_plus/models/users/user_status.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart';
@ -13,9 +12,7 @@ import 'package:contacts_plus_plus/apis/user_api.dart';
import 'package:contacts_plus_plus/clients/notification_client.dart';
import 'package:contacts_plus_plus/models/users/friend.dart';
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/config.dart';
import 'package:contacts_plus_plus/models/message.dart';
import 'package:uuid/uuid.dart';
enum EventType {
undefined,
@ -39,16 +36,14 @@ enum EventTarget {
factory EventTarget.parse(String? text) {
if (text == null) return EventTarget.unknown;
return EventTarget.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(),
return EventTarget.values.firstWhere(
(element) => element.name.toLowerCase() == text.toLowerCase(),
orElse: () => EventTarget.unknown,
);
}
}
class MessagingClient extends ChangeNotifier {
static const String _eofChar = "";
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$_eofChar";
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
static const Duration _autoRefreshDuration = Duration(seconds: 10);
static const Duration _unreadSafeguardDuration = Duration(seconds: 120);
static const String _messageBoxKey = "message-box";
@ -58,28 +53,24 @@ class MessagingClient extends ChangeNotifier {
final List<Friend> _sortedFriendsCache = []; // Keep a sorted copy so as to not have to sort during build()
final Map<String, MessageCache> _messageCache = {};
final Map<String, List<Message>> _unreads = {};
final Logger _logger = Logger("NeosHub");
final Logger _logger = Logger("Messaging");
final NotificationClient _notificationClient;
final HubManager _hubManager = HubManager();
Friend? selectedFriend;
Timer? _notifyOnlineTimer;
Timer? _autoRefresh;
Timer? _unreadSafeguard;
int _attempts = 0;
WebSocket? _wsChannel;
bool _isConnecting = false;
String? _initStatus;
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
: _apiClient = apiClient, _notificationClient = notificationClient {
: _apiClient = apiClient,
_notificationClient = notificationClient {
debugPrint("mClient created: $hashCode");
Hive.openBox(_messageBoxKey).then((box) async {
box.delete(_lastUpdateKey);
await refreshFriendsListWithErrorHandler();
await _refreshUnreads();
_unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads());
});
_startWebsocket();
_setupHub();
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
// We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this
// but I don't feel like implementing that right now.
@ -93,21 +84,20 @@ class MessagingClient extends ChangeNotifier {
_autoRefresh?.cancel();
_notifyOnlineTimer?.cancel();
_unreadSafeguard?.cancel();
_wsChannel?.close();
_hubManager.dispose();
super.dispose();
}
String? get initStatus => _initStatus;
bool get websocketConnected => _wsChannel != null;
List<Friend> get cachedFriends => _sortedFriendsCache;
List<Message> getUnreadsForFriend(Friend friend) => _unreads[friend.id] ?? [];
bool friendHasUnreads(Friend friend) => _unreads.containsKey(friend.id);
bool messageIsUnread(Message message) => _unreads[message.senderId]?.any((element) => element.id == message.id) ?? false;
bool messageIsUnread(Message message) =>
_unreads[message.senderId]?.any((element) => element.id == message.id) ?? false;
Friend? getAsFriend(String userId) => Friend.fromMapOrNull(Hive.box(_messageBoxKey).get(userId));
@ -115,8 +105,7 @@ class MessagingClient extends ChangeNotifier {
MessageCache _createUserMessageCache(String userId) => MessageCache(apiClient: _apiClient, userId: userId);
Future<void> refreshFriendsListWithErrorHandler () async {
Future<void> refreshFriendsListWithErrorHandler() async {
try {
await refreshFriendsList();
} catch (e) {
@ -132,7 +121,7 @@ class MessagingClient extends ChangeNotifier {
final friends = await ContactApi.getFriendsList(_apiClient, lastStatusUpdate: lastUpdateUtc);
for (final friend in friends) {
await _updateFriend(friend);
await _updateContact(friend);
}
_initStatus = "";
@ -141,7 +130,7 @@ class MessagingClient extends ChangeNotifier {
void sendMessage(Message message) {
final msgBody = message.toMap();
_send("SendMessage", body: msgBody);
_hubManager.send("SendMessage", arguments: [msgBody]);
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
cache.addMessage(message);
notifyListeners();
@ -149,7 +138,7 @@ class MessagingClient extends ChangeNotifier {
void markMessagesRead(MarkReadBatch batch) {
final msgBody = batch.toMap();
_send("MarkMessagesRead", body: msgBody);
_hubManager.send("MarkMessagesRead", arguments: [msgBody]);
clearUnreadsForUser(batch.senderId);
}
@ -201,7 +190,7 @@ class MessagingClient extends ChangeNotifier {
final friend = getAsFriend(userId);
if (friend == null) return;
final newStatus = await UserApi.getUserStatus(_apiClient, userId: userId);
await _updateFriend(friend.copyWith(userStatus: newStatus));
await _updateContact(friend.copyWith(userStatus: newStatus));
notifyListeners();
}
@ -228,7 +217,7 @@ class MessagingClient extends ChangeNotifier {
});
}
Future<void> _updateFriend(Friend friend) async {
Future<void> _updateContact(Friend friend) async {
final box = Hive.box(_messageBoxKey);
box.put(friend.id, friend.toMap());
final lastStatusUpdate = box.get(_lastUpdateKey);
@ -247,144 +236,79 @@ class MessagingClient extends ChangeNotifier {
_sortFriendsCache();
}
// ===== Websocket Stuff =====
void _onDisconnected(error) async {
_wsChannel = null;
_logger.warning("Neos Hub connection died with error '$error', reconnecting...");
await _startWebsocket();
}
Future<void> _startWebsocket() async {
Future<void> _setupHub() async {
if (!_apiClient.isAuthenticated) {
_logger.info("Tried to connect to Neos Hub without authentication, this is probably fine for now.");
return;
}
if (_isConnecting) {
return;
}
_isConnecting = true;
_wsChannel = await _tryConnect();
_isConnecting = false;
_logger.info("Connected to Neos Hub.");
_wsChannel!.done.then((error) => _onDisconnected(error));
_wsChannel!.listen(_handleEvent, onDone: () => _onDisconnected("Connection closed."), onError: _onDisconnected);
_wsChannel!.add(_negotiationPacket);
_send("InitializeStatus");
_hubManager.setHeaders(_apiClient.authorizationHeader);
_hubManager.setHandler(EventTarget.messageSent, _onMessageSent);
_hubManager.setHandler(EventTarget.receiveMessage, _onReceiveMessage);
_hubManager.setHandler(EventTarget.messagesRead, _onMessagesRead);
_hubManager.setHandler(EventTarget.receiveStatusUpdate, _onReceiveStatusUpdate);
await _hubManager.start();
_hubManager.send(
"InitializeStatus",
responseHandler: (Map data) async {
final rawContacts = data["contacts"] as List;
final contacts = rawContacts.map((e) => Friend.fromMap(e)).toList();
for (final contact in contacts) {
await _updateContact(contact);
}
_initStatus = "";
notifyListeners();
await _refreshUnreads();
_unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads());
_hubManager.send("RequestStatus", arguments: [null, false]);
},
);
}
Future<WebSocket> _tryConnect() async {
while (true) {
try {
final ws = await WebSocket.connect(Config.resoniteHubUrl.replaceFirst("https://", "wss://"), headers: _apiClient.authorizationHeader);
_attempts = 0;
return ws;
} catch (e) {
final timeout = _reconnectTimeoutsSeconds[_attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)];
_logger.severe(e);
_logger.severe("Retrying in $timeout seconds");
await Future.delayed(Duration(seconds: timeout));
_attempts++;
void _onMessageSent(List args) {
final msg = args[0];
final message = Message.fromMap(msg, withState: MessageState.sent);
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
cache.addMessage(message);
notifyListeners();
}
void _onReceiveMessage(List args) {
final msg = args[0];
final message = Message.fromMap(msg);
final cache = getUserMessageCache(message.senderId) ?? _createUserMessageCache(message.senderId);
cache.addMessage(message);
if (message.senderId != selectedFriend?.id) {
addUnread(message);
updateFriendStatus(message.senderId);
} else {
markMessagesRead(MarkReadBatch(senderId: message.senderId, ids: [message.id], readTime: DateTime.now()));
}
notifyListeners();
}
void _onMessagesRead(List args) {
final messageIds = args[0]["ids"] as List;
final recipientId = args[0]["recipientId"];
if (recipientId == null) return;
final cache = getUserMessageCache(recipientId);
if (cache == null) return;
for (var id in messageIds) {
cache.setMessageState(id, MessageState.read);
}
notifyListeners();
}
void _onReceiveStatusUpdate(List args) {
for (final statusUpdate in args) {
final status = UserStatus.fromMap(statusUpdate);
var friend = getAsFriend(statusUpdate["userId"]);
friend = friend?.copyWith(userStatus: status);
if (friend != null) {
_updateContact(friend);
}
}
notifyListeners();
}
void _handleEvent(event) {
final body = jsonDecode((event.toString().replaceAll(_eofChar, "")));
final int? rawType = body["type"];
if (rawType == null) {
_logger.warning("Received empty event, content was $event");
return;
}
if (rawType > EventType.values.length) {
_logger.info("Unhandled event type $rawType: $body");
return;
}
switch (EventType.values[rawType]) {
case EventType.streamItem:
case EventType.completion:
case EventType.streamInvocation:
case EventType.cancelInvocation:
case EventType.undefined:
_logger.info("Received unhandled event: $rawType: $body");
break;
case EventType.invocation:
_logger.info("Received invocation-event.");
_handleInvocation(body);
break;
case EventType.ping:
_logger.info("Received keep-alive.");
break;
case EventType.close:
_logger.severe("Received close-event: ${body["error"]}");
// Should we trigger a manual reconnect here or just let the remote service close the connection?
break;
}
}
void _handleInvocation(body) async {
final target = EventTarget.parse(body["target"]);
final args = body["arguments"];
switch (target) {
case EventTarget.unknown:
_logger.info("Unknown event-target in message: $body");
return;
case EventTarget.messageSent:
final msg = args[0];
final message = Message.fromMap(msg, withState: MessageState.sent);
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
cache.addMessage(message);
notifyListeners();
break;
case EventTarget.receiveMessage:
final msg = args[0];
final message = Message.fromMap(msg);
final cache = getUserMessageCache(message.senderId) ?? _createUserMessageCache(message.senderId);
cache.addMessage(message);
if (message.senderId != selectedFriend?.id) {
addUnread(message);
updateFriendStatus(message.senderId);
} else {
markMessagesRead(MarkReadBatch(senderId: message.senderId, ids: [message.id], readTime: DateTime.now()));
}
notifyListeners();
break;
case EventTarget.messagesRead:
final messageIds = args[0]["ids"] as List;
final recipientId = args[0]["recipientId"];
if (recipientId == null) break;
final cache = getUserMessageCache(recipientId);
if (cache == null) break;
for (var id in messageIds) {
cache.setMessageState(id, MessageState.read);
}
notifyListeners();
break;
case EventTarget.receiveStatusUpdate:
break;
case EventTarget.removeSession:
case EventTarget.receiveSessionUpdate:
// TODO: Handle session updates
_logger.info("Received unhandled invocation event.");
break;
}
}
String _send(String target, {Map? body}) {
final invocationId = const Uuid().v4();
final data = {
"type": EventType.invocation.index,
"invocationId": invocationId,
"target": target,
"arguments": [
if (body != null)
body
],
};
if (_wsChannel == null) throw "Neos Hub is not connected";
_wsChannel!.add(jsonEncode(data)+_eofChar);
return invocationId;
}
}
}

134
lib/hub_manager.dart Normal file
View file

@ -0,0 +1,134 @@
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/config.dart';
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
class HubManager {
static const String _eofChar = "";
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$_eofChar";
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
final Logger _logger = Logger("Hub");
final Map<String, dynamic> _headers = {};
final Map<EventTarget, dynamic Function(List arguments)> _handlers = {};
final Map<String, dynamic Function(Map result)> _responseHandlers = {};
WebSocket? _wsChannel;
bool _isConnecting = false;
int _attempts = 0;
void setHandler(EventTarget target, Function(List args) function) {
_handlers[target] = function;
}
void setHeaders(Map<String, dynamic> headers) {
_headers.addAll(headers);
}
void _onDisconnected(error) async {
_wsChannel = null;
_logger.warning("Neos Hub connection died with error '$error', reconnecting...");
await start();
}
Future<void> start() async {
if (_isConnecting) {
return;
}
_isConnecting = true;
_wsChannel = await _tryConnect();
_isConnecting = false;
_logger.info("Connected to Neos Hub.");
_wsChannel!.done.then((error) => _onDisconnected(error));
_wsChannel!.listen(_handleEvent, onDone: () => _onDisconnected("Connection closed."), onError: _onDisconnected);
_wsChannel!.add(_negotiationPacket);
}
Future<WebSocket> _tryConnect() async {
while (true) {
try {
final ws = await WebSocket.connect(Config.resoniteHubUrl.replaceFirst("https://", "wss://"), headers: _headers);
_attempts = 0;
return ws;
} catch (e) {
final timeout = _reconnectTimeoutsSeconds[_attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)];
_logger.severe(e);
_logger.severe("Retrying in $timeout seconds");
await Future.delayed(Duration(seconds: timeout));
_attempts++;
}
}
}
void _handleEvent(event) {
final bodies = event.toString().split(_eofChar);
final eventBodies = bodies.whereNot((element) => element.isEmpty).map((e) => jsonDecode(e));
for (final body in eventBodies) {
final int? rawType = body["type"];
if (rawType == null) {
_logger.warning("Received empty event, content was $event");
continue;
}
if (rawType > EventType.values.length) {
_logger.info("Unhandled event type $rawType: $body");
continue;
}
switch (EventType.values[rawType]) {
case EventType.streamItem:
case EventType.completion:
final handler = _responseHandlers[body["invocationId"]];
handler?.call(body["result"] ?? {});
break;
case EventType.cancelInvocation:
case EventType.undefined:
_logger.info("Received unhandled event: $rawType: $body");
break;
case EventType.streamInvocation:
case EventType.invocation:
_logger.info("Received invocation-event.");
_handleInvocation(body);
break;
case EventType.ping:
_logger.info("Received keep-alive.");
break;
case EventType.close:
_logger.severe("Received close-event: ${body["error"]}");
// Should we trigger a manual reconnect here or just let the remote service close the connection?
break;
}
}
}
void _handleInvocation(body) async {
final target = EventTarget.parse(body["target"]);
final args = body["arguments"] ?? [];
final handler = _handlers[target];
if (handler == null) {
_logger.info("Unhandled event received");
return;
}
handler(args);
}
void send(String target, {List arguments = const [], Function(Map data)? responseHandler}) {
final invocationId = const Uuid().v4();
final data = {
"type": EventType.invocation.index,
"invocationId": invocationId,
"target": target,
"arguments": arguments,
};
if (responseHandler != null) {
_responseHandlers[invocationId] = responseHandler;
}
if (_wsChannel == null) throw "Neos Hub is not connected";
_wsChannel!.add(jsonEncode(data) + _eofChar);
}
void dispose() {
_wsChannel?.close();
}
}

View file

@ -177,7 +177,6 @@ class SessionFilterSettings {
String buildRequestString() => "?includeEmptyHeadless=$includeEmptyHeadless"
"${"&includeEnded=$includeEnded"}"
"${name.isNotEmpty ? "&name=$name" : ""}"
"${!includeIncompatible ? "&compatibilityHash=${Uri.encodeComponent(Config.latestCompatHash)}" : ""}"
"${hostName.isNotEmpty ? "&hostName=$hostName" : ""}"
"${minActiveUsers > 0 ? "&minActiveUsers=$minActiveUsers" : ""}";

View file

@ -3,26 +3,27 @@ import 'package:contacts_plus_plus/models/users/user_profile.dart';
import 'package:contacts_plus_plus/models/users/friend_status.dart';
import 'package:contacts_plus_plus/models/users/online_status.dart';
import 'package:contacts_plus_plus/models/users/user_status.dart';
import 'package:flutter/foundation.dart';
class Friend implements Comparable {
static const _emptyId = "-1";
static const _neosBotId = "U-Neos";
static const _neosBotId = "U-Resonite";
final String id;
final String username;
final String ownerId;
final UserStatus userStatus;
final UserProfile userProfile;
final FriendStatus friendStatus;
final FriendStatus contactStatus;
final DateTime latestMessageTime;
const Friend({required this.id, required this.username, required this.ownerId, required this.userStatus, required this.userProfile,
required this.friendStatus, required this.latestMessageTime,
required this.contactStatus, required this.latestMessageTime,
});
bool get isHeadless => userStatus.activeSessions.any((session) => session.headlessHost == true && session.hostUserId == id);
factory Friend.fromMap(Map map) {
final userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]);
var userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]);
return Friend(
id: map["id"],
username: map["contactUsername"],
@ -30,7 +31,7 @@ class Friend implements Comparable {
// Neos bot status is always offline but should be displayed as online
userStatus: map["id"] == _neosBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus,
userProfile: UserProfile.fromMap(map["profile"] ?? {}),
friendStatus: FriendStatus.fromString(map["contactStatus"]),
contactStatus: FriendStatus.fromString(map["contactStatus"]),
latestMessageTime: map["latestMessageTime"] == null
? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(map["latestMessageTime"]),
);
@ -48,7 +49,7 @@ class Friend implements Comparable {
ownerId: "",
userStatus: UserStatus.empty(),
userProfile: UserProfile.empty(),
friendStatus: FriendStatus.none,
contactStatus: FriendStatus.none,
latestMessageTime: DateTimeX.epoch
);
}
@ -64,7 +65,7 @@ class Friend implements Comparable {
ownerId: ownerId ?? this.ownerId,
userStatus: userStatus ?? this.userStatus,
userProfile: userProfile ?? this.userProfile,
friendStatus: friendStatus ?? this.friendStatus,
contactStatus: friendStatus ?? this.contactStatus,
latestMessageTime: latestMessageTime ?? this.latestMessageTime,
);
}
@ -72,11 +73,11 @@ class Friend implements Comparable {
Map toMap({bool shallow=false}) {
return {
"id": id,
"username": username,
"contactUsername": username,
"ownerId": ownerId,
"userStatus": userStatus.toMap(shallow: shallow),
"profile": userProfile.toMap(),
"friendStatus": friendStatus.name,
"contactStatus": contactStatus.name,
"latestMessageTime": latestMessageTime.toIso8601String(),
};
}

View file

@ -19,7 +19,7 @@ enum OnlineStatus {
factory OnlineStatus.fromString(String? text) {
return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
orElse: () => OnlineStatus.offline,
orElse: () => OnlineStatus.online,
);
}

View file

@ -1,3 +1,4 @@
import 'package:contacts_plus_plus/config.dart';
import 'package:contacts_plus_plus/models/session.dart';
import 'package:contacts_plus_plus/models/users/online_status.dart';
@ -9,20 +10,26 @@ class UserStatus {
final bool currentHosting;
final Session currentSession;
final List<Session> activeSessions;
final String neosVersion;
final String appVersion;
final String outputDevice;
final bool isMobile;
final String compatibilityHash;
const UserStatus(
{required this.onlineStatus, required this.lastStatusChange, required this.currentSession,
required this.currentSessionAccessLevel, required this.currentSessionHidden, required this.currentHosting,
required this.activeSessions, required this.neosVersion, required this.outputDevice, required this.isMobile,
required this.compatibilityHash,
});
const UserStatus({
required this.onlineStatus,
required this.lastStatusChange,
required this.currentSession,
required this.currentSessionAccessLevel,
required this.currentSessionHidden,
required this.currentHosting,
required this.activeSessions,
required this.appVersion,
required this.outputDevice,
required this.isMobile,
required this.compatibilityHash,
});
factory UserStatus.empty() =>
UserStatus(
factory UserStatus.empty() => UserStatus(
onlineStatus: OnlineStatus.offline,
lastStatusChange: DateTime.now(),
currentSessionAccessLevel: 0,
@ -30,14 +37,14 @@ class UserStatus {
currentHosting: false,
currentSession: Session.none(),
activeSessions: [],
neosVersion: "",
appVersion: "",
outputDevice: "Unknown",
isMobile: false,
compatibilityHash: "",
);
factory UserStatus.fromMap(Map map) {
final statusString = map["onlineStatus"] as String?;
final statusString = map["onlineStatus"].toString();
final status = OnlineStatus.fromString(statusString);
return UserStatus(
onlineStatus: status,
@ -47,11 +54,10 @@ class UserStatus {
currentHosting: map["currentHosting"] ?? false,
currentSession: Session.fromMap(map["currentSession"]),
activeSessions: (map["activeSessions"] as List? ?? []).map((e) => Session.fromMap(e)).toList(),
neosVersion: map["neosVersion"] ?? "",
appVersion: map["appVersion"] ?? "",
outputDevice: map["outputDevice"] ?? "Unknown",
isMobile: map["isMobile"] ?? false,
compatibilityHash: map["compatabilityHash"] ?? ""
);
compatibilityHash: map["compatabilityHash"] ?? "");
}
Map toMap({bool shallow = false}) {
@ -62,8 +68,14 @@ class UserStatus {
"currentSessionHidden": currentSessionHidden,
"currentHosting": currentHosting,
"currentSession": currentSession.isNone || shallow ? null : currentSession.toMap(),
"activeSessions": shallow ? [] : activeSessions.map((e) => e.toMap(),).toList(),
"neosVersion": neosVersion,
"activeSessions": shallow
? []
: activeSessions
.map(
(e) => e.toMap(),
)
.toList(),
"neosVersion": appVersion,
"outputDevice": outputDevice,
"isMobile": isMobile,
"compatibilityHash": compatibilityHash,
@ -91,9 +103,9 @@ class UserStatus {
currentHosting: currentHosting ?? this.currentHosting,
currentSession: currentSession ?? this.currentSession,
activeSessions: activeSessions ?? this.activeSessions,
neosVersion: neosVersion ?? this.neosVersion,
appVersion: neosVersion ?? this.appVersion,
outputDevice: outputDevice ?? this.outputDevice,
isMobile: isMobile ?? this.isMobile,
compatibilityHash: compatibilityHash ?? this.compatibilityHash,
);
}
}

View file

@ -9,7 +9,7 @@ class FriendOnlineStatusIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return userStatus.neosVersion.contains("Contacts++") && userStatus.onlineStatus != OnlineStatus.offline
return userStatus.appVersion.contains("Contacts++") && userStatus.onlineStatus != OnlineStatus.offline
? SizedBox.square(
dimension: 10,
child: Image.asset(