Compare commits

...

3 Commits

Author SHA1 Message Date
10b86812cd fix(service): Fix conversations not being properly loaded
Meanwhile, also remove the Completer system for preventing race
conditions with, hopefully, something better.
2022-11-25 17:58:09 +01:00
fe4c794f68 refactor(service): Conversations now point to the last message 2022-11-25 17:01:30 +01:00
6115d748e3 feat(ui): Show the last message state in the conversations list 2022-11-25 16:09:06 +01:00
22 changed files with 313 additions and 176 deletions

View File

@ -9,6 +9,7 @@ import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
@ -99,8 +100,6 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
// Padding(padding: ..., child: Column(children: [ ... ]))
// TODO(Unknown): Theme the switches
void main() async {
GetIt.I.registerSingleton<Completer<void>>(Completer());
setupLogging();
await setupUIServices();
@ -190,10 +189,10 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Lift the UI block
GetIt.I.get<Completer<void>>().complete();
_setupSharingHandler();
// Lift the UI block
GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
}
Future<void> _handleSharedMedia(SharedMedia media) async {

View File

@ -5,6 +5,7 @@ import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/shared/cache.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/message.dart';
class ConversationService {
ConversationService()
@ -55,10 +56,8 @@ class ConversationService {
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache.
Future<Conversation> updateConversation(int id, {
String? lastMessageBody,
int? lastChangeTimestamp,
bool? lastMessageRetracted,
int? lastMessageId,
Message? lastMessage,
bool? open,
int? unreadCounter,
String? avatarUrl,
@ -66,21 +65,24 @@ class ConversationService {
bool? muted,
bool? encrypted,
}) async {
final conversation = await _getConversationById(id);
final newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
final conversation = (await _getConversationById(id))!;
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
id,
lastMessageBody: lastMessageBody,
lastMessageRetracted: lastMessageRetracted,
lastMessageId: lastMessageId,
lastMessage: lastMessage,
lastChangeTimestamp: lastChangeTimestamp,
open: open,
unreadCounter: unreadCounter,
avatarUrl: avatarUrl,
chatState: conversation?.chatState ?? ChatState.gone,
chatState: conversation.chatState,
muted: muted,
encrypted: encrypted,
);
// Copy over the old lastMessage if a new one was not set
if (conversation.lastMessage != null && lastMessage == null) {
newConversation = newConversation.copyWith(lastMessage: conversation.lastMessage);
}
_conversationCache.cache(id, newConversation);
return newConversation;
}
@ -88,9 +90,7 @@ class ConversationService {
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the cache.
Future<Conversation> addConversationFromData(
String title,
int lastMessageId,
bool lastMessageRetracted,
String lastMessageBody,
Message? lastMessage,
String avatarUrl,
String jid,
int unreadCounter,
@ -101,9 +101,7 @@ class ConversationService {
) async {
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
title,
lastMessageId,
lastMessageRetracted,
lastMessageBody,
lastMessage,
avatarUrl,
jid,
unreadCounter,

View File

@ -1,5 +1,5 @@
const conversationsTable = 'Conversations';
const messsagesTable = 'Messages';
const messagesTable = 'Messages';
const rosterTable = 'RosterItems';
const mediaTable = 'SharedMedia';
const preferenceTable = 'Preferences';

View File

@ -19,7 +19,7 @@ Future<void> createDatabase(Database db, int version) async {
// Messages
await db.execute(
'''
CREATE TABLE $messsagesTable (
CREATE TABLE $messagesTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
body TEXT,
@ -52,7 +52,7 @@ Future<void> createDatabase(Database db, int version) async {
isUploading INTEGER NOT NULL,
mediaSize INTEGER,
isRetracted INTEGER,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messsagesTable (id)
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
)''',
);
@ -66,12 +66,11 @@ Future<void> createDatabase(Database db, int version) async {
avatarUrl TEXT NOT NULL,
lastChangeTimestamp INTEGER NOT NULL,
unreadCounter INTEGER NOT NULL,
lastMessageBody TEXT NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId INTEGER NOT NULL,
lastMessageRetracted INTEGER NOT NULL,
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id)
)''',
);
@ -86,7 +85,7 @@ Future<void> createDatabase(Database db, int version) async {
conversation_id INTEGER NOT NULL,
message_id INTEGER,
FOREIGN KEY (conversation_id) REFERENCES $conversationsTable (id),
FOREIGN KEY (message_id) REFERENCES $messsagesTable (id)
FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
)''',
);

View File

@ -8,6 +8,8 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/creation.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart';
import 'package:moxxyv2/service/database/migrations/0000_language.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction_conversation.dart';
@ -58,7 +60,7 @@ class DatabaseService {
_db = await openDatabase(
dbPath,
password: key,
version: 6,
version: 8,
onCreate: createDatabase,
onConfigure: configureDatabase,
onUpgrade: (db, oldVersion, newVersion) async {
@ -82,6 +84,14 @@ class DatabaseService {
_log.finest('Running migration for database version 6');
await upgradeFromV5ToV6(db);
}
if (oldVersion < 7) {
_log.finest('Running migration for database version 7');
await upgradeFromV6ToV7(db);
}
if (oldVersion < 8) {
_log.finest('Running migration for database version 8');
await upgradeFromV7ToV8(db);
}
},
);
@ -107,17 +117,21 @@ class DatabaseService {
final rosterItem = await GetIt.I.get<RosterService>()
.getRosterItemByJid(c['jid']! as String);
Message? lastMessage;
if (c['lastMessageId'] != null) {
lastMessage = await getMessageById(c['lastMessageId']! as int);
}
tmp.add(
Conversation.fromDatabaseJson(
c,
rosterItem != null,
rosterItem?.subscription ?? 'none',
sharedMediaRaw,
lastMessage,
),
);
}
_log.finest(tmp.toString());
return tmp;
}
@ -151,10 +165,8 @@ class DatabaseService {
/// Updates the conversation with id [id] inside the database.
Future<Conversation> updateConversation(int id, {
String? lastMessageBody,
int? lastChangeTimestamp,
bool? lastMessageRetracted,
int? lastMessageId,
Message? lastMessage,
bool? open,
int? unreadCounter,
String? avatarUrl,
@ -176,15 +188,8 @@ class DatabaseService {
orderBy: 'timestamp DESC',
)).map(SharedMedium.fromDatabaseJson);
//await c.sharedMedia.load();
if (lastMessageBody != null) {
c['lastMessageBody'] = lastMessageBody;
}
if (lastMessageRetracted != null) {
c['lastMessageRetracted'] = boolToInt(lastMessageRetracted);
}
if (lastMessageId != null) {
c['lastMessageId'] = lastMessageId;
if (lastMessage != null) {
c['lastMessageId'] = lastMessage.id;
}
if (lastChangeTimestamp != null) {
c['lastChangeTimestamp'] = lastChangeTimestamp;
@ -218,6 +223,7 @@ class DatabaseService {
rosterItem != null,
rosterItem?.subscription ?? 'none',
sharedMedia.map((m) => m.toJson()).toList(),
lastMessage,
);
}
@ -225,9 +231,7 @@ class DatabaseService {
/// [Conversation] object can carry its database id.
Future<Conversation> addConversationFromData(
String title,
int lastMessageId,
bool lastMessageRetracted,
String lastMessageBody,
Message? lastMessage,
String avatarUrl,
String jid,
int unreadCounter,
@ -239,9 +243,7 @@ class DatabaseService {
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
final conversation = Conversation(
title,
lastMessageId,
lastMessageRetracted,
lastMessageBody,
lastMessage,
avatarUrl,
jid,
unreadCounter,

View File

@ -0,0 +1,13 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV6ToV7(Database db) async {
await db.execute(
'ALTER TABLE $conversationsTable ADD COLUMN lastMessageState INTEGER NOT NULL DEFAULT 0;'
);
await db.execute(
"ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';"
);
}

View File

@ -0,0 +1,18 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV7ToV8(Database db) async {
// TODO(PapaTutuWawa): Make lastMessageId a foreign key constraint
await db.execute(
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageState;'
);
await db.execute(
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageSender;"
);
await db.execute(
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageBody;"
);
await db.execute(
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageRetracted;"
);
}

View File

@ -6,6 +6,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV3ToV4(Database db) async {
// Mark all messages as not retracted
await db.execute(
'ALTER TABLE $messsagesTable ADD COLUMN isRetracted INTEGER DEFAULT ${boolToInt(false)};',
'ALTER TABLE $messagesTable ADD COLUMN isRetracted INTEGER DEFAULT ${boolToInt(false)};',
);
}

View File

@ -5,6 +5,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV5ToV6(Database db) async {
// Allow shared media to reference a message
await db.execute(
'ALTER TABLE $mediaTable ADD COLUMN message_id INTEGER REFERENCES $messsagesTable (id);',
'ALTER TABLE $mediaTable ADD COLUMN message_id INTEGER REFERENCES $messagesTable (id);',
);
}

View File

@ -29,6 +29,7 @@ import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:permission_handler/permission_handler.dart';
void setupBackgroundEventHandler() {
@ -68,6 +69,9 @@ void setupBackgroundEventHandler() {
]);
GetIt.I.registerSingleton<EventHandler>(handler);
GetIt.I.registerSingleton<SynchronizedQueue<Map<String, dynamic>?>>(
SynchronizedQueue<Map<String, dynamic>?>(handleUIEvent),
);
}
Future<void> performLogin(LoginCommand command, { dynamic extra }) async {
@ -141,13 +145,6 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra }) async {
final id = extra as String;
// Prevent a race condition where the UI sends the prestart command before the service
// has finished setting everything up
GetIt.I.get<Logger>().finest('Waiting for preStart future to complete..');
await GetIt.I.get<Completer<void>>().future;
GetIt.I.get<Logger>().finest('PreStart future done');
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
// Set the locale very early
@ -209,9 +206,7 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
} else {
final conversation = await cs.addConversationFromData(
command.title,
-1,
false,
command.lastMessageBody,
null,
command.avatarUrl,
command.jid,
0,
@ -335,9 +330,7 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
} else {
final c = await cs.addConversationFromData(
jid.split('@')[0],
-1,
false,
'',
null,
'',
jid,
0,
@ -621,14 +614,10 @@ Future<void> performMarkConversationAsRead(MarkConversationAsReadCommand command
sendEvent(ConversationUpdatedEvent(conversation: conversation));
final msg = await GetIt.I.get<MessageService>().getMessageById(
conversation.jid,
conversation.lastMessageId,
);
if (msg != null) {
if (conversation.lastMessage != null) {
await GetIt.I.get<XmppService>().sendReadMarker(
conversation.jid,
msg.sid,
conversation.lastMessage!.sid,
);
}
}

View File

@ -239,17 +239,16 @@ class MessageService {
mediaSize: null,
isRetracted: true,
thumbnailData: null,
body: '',
);
sendEvent(MessageUpdatedEvent(message: retractedMessage));
final cs = GetIt.I.get<ConversationService>();
final conversation = await cs.getConversationByJid(conversationJid);
if (conversation != null) {
if (conversation.lastMessageId == msg.id) {
var newConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: '',
lastMessageRetracted: true,
if (conversation.lastMessage?.id == msg.id) {
var newConversation = conversation.copyWith(
lastMessage: retractedMessage,
);
if (isMedia) {
@ -260,7 +259,6 @@ class MessageService {
return medium.messageId != msg.id;
}).toList(),
);
GetIt.I.get<ConversationService>().setConversation(newConversation);
// Delete the file if we downloaded it
if (mediaUrl != null) {
@ -271,6 +269,7 @@ class MessageService {
}
}
cs.setConversation(newConversation);
sendEvent(
ConversationUpdatedEvent(
conversation: newConversation,

View File

@ -35,6 +35,7 @@ import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/logging.dart';
import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:moxxyv2/ui/events.dart' as ui_events;
Future<void> initializeServiceIfNeeded() async {
@ -47,7 +48,7 @@ Future<void> initializeServiceIfNeeded() async {
}
logger.info('Attaching to service...');
await handler.attach(ui_events.handleIsolateEvent);
await handler.attach(ui_events.receiveIsolateEvent);
logger.info('Done');
// ignore: cascade_invocations
@ -62,7 +63,7 @@ Future<void> initializeServiceIfNeeded() async {
logger.info('Service is not running. Initializing service... ');
await handler.start(
entrypoint,
handleUiEvent,
receiveUIEvent,
ui_events.handleIsolateEvent,
);
}
@ -72,7 +73,7 @@ Future<void> initializeServiceIfNeeded() async {
/// logging what we send.
void sendEvent(BackgroundEvent event, { String? id }) {
// NOTE: *S*erver to *F*oreground
GetIt.I.get<Logger>().fine('S2F: ${event.toJson()}');
GetIt.I.get<Logger>().fine('--> ${event.toJson()["type"]}');
GetIt.I.get<BackgroundService>().sendEvent(event, id: id);
}
@ -130,16 +131,13 @@ Future<void> initUDPLogger() async {
/// The entrypoint for all platforms after the platform specific initilization is done.
@pragma('vm:entry-point')
Future<void> entrypoint() async {
// Register the lock
GetIt.I.registerSingleton<Completer<void>>(Completer());
setupLogging();
setupBackgroundEventHandler();
// Register singletons
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
GetIt.I.registerSingleton<LanguageService>(LanguageService());
setupLogging();
setupBackgroundEventHandler();
// Initialize the database
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
@ -242,13 +240,15 @@ Future<void> entrypoint() async {
);
}
GetIt.I.get<Logger>().finest('Resolving startup future');
GetIt.I.get<Completer<void>>().complete();
unawaited(GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock());
sendEvent(ServiceReadyEvent());
}
Future<void> handleUiEvent(Map<String, dynamic>? data) async {
Future<void> receiveUIEvent(Map<String, dynamic>? data) async {
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data);
}
Future<void> handleUIEvent(Map<String, dynamic>? data) async {
// NOTE: *F*oreground to *S*ervice
final log = GetIt.I.get<Logger>();

View File

@ -139,7 +139,7 @@ class XmppService {
final cs = GetIt.I.get<ConversationService>();
final conn = GetIt.I.get<XmppConnection>();
final timestamp = DateTime.now().millisecondsSinceEpoch;
for (final recipient in recipients) {
final sid = conn.generateId();
final originId = conn.generateId();
@ -158,9 +158,7 @@ class XmppService {
);
final newConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: body,
lastMessageId: message.id,
lastMessageRetracted: false,
lastMessage: message,
lastChangeTimestamp: timestamp,
);
@ -364,7 +362,7 @@ class XmppService {
// Recipient -> Should encrypt
final encrypt = <String, bool>{};
// Recipient -> Last message Id
final lastMessageIds = <String, int>{};
final lastMessages = <String, Message>{};
// Create the messages and shared media entries
final conn = GetIt.I.get<XmppConnection>();
@ -412,7 +410,7 @@ class XmppService {
}
if (path == paths.last) {
lastMessageIds[recipient] = msg.id;
lastMessages[recipient] = msg;
}
sendEvent(MessageAddedEvent(message: msg));
@ -424,14 +422,12 @@ class XmppService {
final sharedMediaMap = <String, List<SharedMedium>>{};
final rs = GetIt.I.get<RosterService>();
for (final recipient in recipients) {
final lastFileMime = lookupMimeType(paths.last);
final conversation = await cs.getConversationByJid(recipient);
if (conversation != null) {
// Update conversation
var updatedConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: mimeTypeToEmoji(lastFileMime),
lastMessageId: lastMessageIds[recipient],
lastMessage: lastMessages[recipient],
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
open: true,
);
@ -452,9 +448,7 @@ class XmppService {
var newConversation = await cs.addConversationFromData(
// TODO(Unknown): Should we use the JID parser?
rosterItem?.title ?? recipient.split('@').first,
lastMessageIds[recipient]!,
false,
mimeTypeToEmoji(lastFileMime),
lastMessages[recipient],
rosterItem?.avatarUrl ?? '',
recipient,
0,
@ -653,9 +647,7 @@ class XmppService {
final bare = event.from.toBare();
final conv = await cs.addConversationFromData(
bare.toString().split('@')[0],
-1,
false,
'',
null,
'', // TODO(Unknown): avatarUrl
bare.toString(),
0,
@ -693,7 +685,9 @@ class XmppService {
final db = GetIt.I.get<DatabaseService>();
final ms = GetIt.I.get<MessageService>();
final dbMsg = await db.getMessageByXmppId(event.id, event.from.toBare().toString());
final cs = GetIt.I.get<ConversationService>();
final sender = event.from.toBare().toString();
final dbMsg = await db.getMessageByXmppId(event.id, sender);
if (dbMsg == null) {
_log.warning('Did not find the message in the database!');
return;
@ -704,8 +698,15 @@ class XmppService {
received: dbMsg.received || event.type == 'received' || event.type == 'displayed',
displayed: dbMsg.displayed || event.type == 'displayed',
);
sendEvent(MessageUpdatedEvent(message: msg));
// Update the conversation
final conv = await cs.getConversationByJid(sender);
if (conv != null && conv.lastMessage?.id == msg.id) {
final newConv = conv.copyWith(lastMessage: msg);
cs.setConversation(newConv);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
}
Future<void> _onChatState(ChatState state, String jid) async {
@ -989,10 +990,8 @@ class XmppService {
// The conversation exists, so we can just update it
final newConversation = await cs.updateConversation(
conversation.id,
lastMessageBody: conversationBody,
lastMessage: message,
lastChangeTimestamp: messageTimestamp,
lastMessageId: message.id,
lastMessageRetracted: false,
// Do not increment the counter for messages we sent ourselves (via Carbons)
// or if we have the chat currently opened
unreadCounter: isConversationOpened || sent
@ -1017,9 +1016,7 @@ class XmppService {
// The conversation does not exist, so we must create it
final newConversation = await cs.addConversationFromData(
rosterItem?.title ?? conversationJid.split('@')[0],
message.id,
false,
conversationBody,
message,
rosterItem?.avatarUrl ?? '',
conversationJid,
sent ? 0 : 1,
@ -1128,11 +1125,20 @@ class XmppService {
Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {
final jid = JID.fromString(event.stanza.to!).toBare().toString();
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
final msg = await ms.getMessageByStanzaId(jid, event.stanza.id!);
if (msg != null) {
// Ack the message
final newMsg = await ms.updateMessage(msg.id, acked: true);
sendEvent(MessageUpdatedEvent(message: newMsg));
// Ack the conversation
final conv = await cs.getConversationByJid(jid);
if (conv != null && conv.lastMessage?.id == newMsg.id) {
final newConv = conv.copyWith(lastMessage: msg);
cs.setConversation(newConv);
sendEvent(ConversationUpdatedEvent(conversation: newConv));
}
} else {
_log.finest('Wanted to mark message as acked but did not find the message to ack');
}

View File

@ -2,6 +2,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart';
part 'conversation.freezed.dart';
part 'conversation.g.dart';
@ -18,14 +19,27 @@ class ConversationChatStateConverter implements JsonConverter<ChatState, Map<Str
};
}
class ConversationMessageConverter implements JsonConverter<Message?, Map<String, dynamic>> {
const ConversationMessageConverter();
@override
Message? fromJson(Map<String, dynamic> json) {
if (json['message'] == null) return null;
return Message.fromJson(json['message']! as Map<String, dynamic>);
}
@override
Map<String, dynamic> toJson(Message? message) => <String, dynamic>{
'message': message?.toJson(),
};
}
@freezed
class Conversation with _$Conversation {
factory Conversation(
String title,
// NOTE: The internal database Id of the message
int lastMessageId,
bool lastMessageRetracted,
String lastMessageBody,
@ConversationMessageConverter() Message? lastMessage,
String avatarUrl,
String jid,
int unreadCounter,
@ -52,7 +66,7 @@ class Conversation with _$Conversation {
/// JSON
factory Conversation.fromJson(Map<String, dynamic> json) => _$ConversationFromJson(json);
factory Conversation.fromDatabaseJson(Map<String, dynamic> json, bool inRoster, String subscription, List<Map<String, dynamic>> sharedMedia) {
factory Conversation.fromDatabaseJson(Map<String, dynamic> json, bool inRoster, String subscription, List<Map<String, dynamic>> sharedMedia, Message? lastMessage) {
return Conversation.fromJson({
...json,
'muted': intToBool(json['muted']! as int),
@ -62,7 +76,9 @@ class Conversation with _$Conversation {
'subscription': subscription,
'encrypted': intToBool(json['encrypted']! as int),
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
'lastMessageRetracted': intToBool(json['lastMessageRetracted']! as int)
'lastMessage': <String, dynamic>{
'message': lastMessage?.toJson(),
},
});
}
@ -72,14 +88,15 @@ class Conversation with _$Conversation {
..remove('chatState')
..remove('sharedMedia')
..remove('inRoster')
..remove('subscription');
..remove('subscription')
..remove('lastMessage');
return {
...map,
'open': boolToInt(open),
'muted': boolToInt(muted),
'encrypted': boolToInt(encrypted),
'lastMessageRetracted': boolToInt(lastMessageRetracted),
'lastMessage': lastMessage?.id,
};
}
}

View File

@ -0,0 +1,43 @@
import 'dart:async';
import 'dart:collection';
import 'package:synchronized/synchronized.dart';
/// The function of this class is essentially a queue, that processes itself as long as
/// _shouldQueue is false. If not, all added items are held until removeQueueLock is
/// called. After that point, all added items bypass the lock and get immediately passed
/// to the callback.
class SynchronizedQueue<T> {
SynchronizedQueue(this._callback);
final Future<void> Function(T) _callback;
final Queue<T> _queue = Queue<T>();
final Lock _lock = Lock();
// If true, then events queue up
bool _shouldQueue = true;
Future<void> add(T item) async {
if (!_shouldQueue) {
unawaited(_callback(item));
return;
}
await _lock.synchronized(() {
if (!_shouldQueue) {
unawaited(_callback(item));
return;
}
_queue.addLast(item);
});
}
Future<void> removeQueueLock() async {
await _lock.synchronized(() async {
while (_queue.isNotEmpty) {
final item = _queue.removeFirst();
await _callback(item);
}
_shouldQueue = false;
});
}
}

View File

@ -8,6 +8,7 @@ import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart' as blocklist;
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
@ -34,6 +35,11 @@ void setupEventHandler() {
]);
GetIt.I.registerSingleton<EventHandler>(handler);
GetIt.I.registerSingleton<SynchronizedQueue<Map<String, dynamic>?>>(SynchronizedQueue<Map<String, dynamic>?>(handleIsolateEvent));
}
Future<void> receiveIsolateEvent(Map<String, dynamic>? json) async {
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(json);
}
Future<void> handleIsolateEvent(Map<String, dynamic>? json) async {
@ -50,7 +56,7 @@ Future<void> handleIsolateEvent(Map<String, dynamic>? json) async {
event,
);
log.finest('S2F: $event');
log.finest('<-- ${(event).toString()}');
// First attempt to deal with awaitables
var found = false;
@ -136,9 +142,6 @@ Future<void> onSelfAvatarChanged(SelfAvatarChangedEvent event, { dynamic extra }
}
Future<void> onServiceReady(ServiceReadyEvent event, { dynamic extra }) async {
GetIt.I.get<Logger>().fine('onServiceReady: Waiting for UI future to resolve...');
await GetIt.I.get<Completer<void>>().future;
GetIt.I.get<Logger>().fine('onServiceReady: Done');
await MoxplatformPlugin.handler.getDataSender().sendData(
PerformPreStartCommand(
systemLocaleCode: WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),

View File

@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
@ -55,15 +54,9 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
itemBuilder: (_context, index) {
final item = state.conversations[index];
final row = ConversationsListRow(
item.avatarUrl,
item.title,
item.lastMessageBody,
item.unreadCounter,
maxTextWidth,
item.lastChangeTimestamp,
item,
true,
typingIndicator: item.chatState == ChatState.composing,
lastMessageRetracted: item.lastMessageRetracted,
key: ValueKey('conversationRow;${item.jid}'),
);

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
@ -94,7 +95,27 @@ class NewConversationPage extends StatelessWidget {
item.avatarUrl,
),
),
child: ConversationsListRow(item.avatarUrl, item.title, item.jid, 0, maxTextWidth, timestampNever, false),
child: ConversationsListRow(
maxTextWidth,
Conversation(
item.title,
null,
item.avatarUrl,
item.jid,
0,
0,
[],
0,
true,
true,
'',
false,
false,
ChatState.gone,
),
false,
showTimestamp: false,
),
),
);
}

View File

@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:move_to_background/move_to_background.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart' as navigation;
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
@ -68,14 +69,26 @@ class ShareSelectionPage extends StatelessWidget {
);
},
child: ConversationsListRow(
item.avatarPath,
item.title,
item.jid,
0,
maxTextWidth,
timestampNever,
Conversation(
item.title,
null,
item.avatarPath,
item.jid,
0,
0,
[],
0,
true,
true,
'',
false,
false,
ChatState.gone,
),
false,
showLock: item.isEncrypted,
showTimestamp: false,
extra: Checkbox(
value: isSelected,
onChanged: (_) {

View File

@ -15,10 +15,6 @@ import 'package:share_handler/share_handler.dart';
/// Handler for when we received a [PreStartDoneEvent].
Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
GetIt.I.get<Logger>().finest('Waiting for UI setup future to complete...');
await GetIt.I.get<Completer<void>>().future;
GetIt.I.get<Logger>().finest('Done');
GetIt.I.get<PreferencesBloc>().add(
PreferencesChangedEvent(result.preferences),
);

View File

@ -5,7 +5,6 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
// TODO(Unknown): Maybe manage this as a sort-of proxy service between the BLoCs and
// the event receiver
class UIDataService {
UIDataService() : isLoggedIn = false;
bool isLoggedIn;

View File

@ -1,39 +1,33 @@
import 'dart:async';
import 'package:badges/badges.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
class ConversationsListRow extends StatefulWidget {
const ConversationsListRow(
this.avatarUrl,
this.name,
this.lastMessageBody,
this.unreadCount,
this.maxTextWidth,
this.lastChangeTimestamp,
this.conversation,
this.update, {
this.showTimestamp = true,
this.showLock = false,
this.typingIndicator = false,
this.extra,
this.lastMessageRetracted = false,
super.key,
}
);
final String avatarUrl;
final String name;
final bool lastMessageRetracted;
final String lastMessageBody;
final int unreadCount;
final Conversation conversation;
final double maxTextWidth;
final int lastChangeTimestamp;
final bool update; // Should a timer run to update the timestamp
final bool typingIndicator;
final bool showLock;
final bool showTimestamp;
final Widget? extra;
@override
@ -51,23 +45,23 @@ class ConversationsListRowState extends State<ConversationsListRow> {
final _now = DateTime.now().millisecondsSinceEpoch;
_timestampString = formatConversationTimestamp(
widget.lastChangeTimestamp,
widget.conversation.lastChangeTimestamp,
_now,
);
// NOTE: We could also check and run the timer hourly, but who has a messenger on the
// conversation screen open for hours on end?
if (widget.update && widget.lastChangeTimestamp > -1 && _now - widget.lastChangeTimestamp >= 60 * Duration.millisecondsPerMinute) {
if (widget.update && widget.conversation.lastChangeTimestamp > -1 && _now - widget.conversation.lastChangeTimestamp >= 60 * Duration.millisecondsPerMinute) {
_updateTimer = Timer.periodic(const Duration(minutes: 1), (timer) {
final now = DateTime.now().millisecondsSinceEpoch;
setState(() {
_timestampString = formatConversationTimestamp(
widget.lastChangeTimestamp,
widget.conversation.lastChangeTimestamp,
now,
);
});
if (now - widget.lastChangeTimestamp >= 60 * Duration.millisecondsPerMinute) {
if (now - widget.conversation.lastChangeTimestamp >= 60 * Duration.millisecondsPerMinute) {
_updateTimer!.cancel();
_updateTimer = null;
}
@ -87,38 +81,69 @@ class ConversationsListRowState extends State<ConversationsListRow> {
}
Widget _buildLastMessageBody() {
if (widget.typingIndicator) {
if (widget.conversation.chatState == ChatState.composing) {
return const TypingIndicatorWidget(Colors.black, Colors.white);
}
String body;
if (widget.conversation.lastMessage == null) {
body = '';
} else {
if (widget.conversation.lastMessage!.isRetracted) {
body = t.messages.retracted;
} else {
body = widget.conversation.lastMessage!.body;
}
}
return Text(
widget.lastMessageRetracted ?
t.messages.retracted :
widget.lastMessageBody,
body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
Widget _getLastMessageIcon() {
final lastMessage = widget.conversation.lastMessage;
if (lastMessage == null) return const SizedBox();
if (lastMessage.displayed) {
return Icon(
Icons.done_all,
color: Colors.blue.shade700,
);
} else if (lastMessage.received) {
return const Icon(Icons.done_all);
} else if (lastMessage.acked) {
return const Icon(Icons.done);
}
return const SizedBox();
}
@override
Widget build(BuildContext context) {
final badgeText = widget.unreadCount > 99 ? '99+' : widget.unreadCount.toString();
final badgeText = widget.conversation.unreadCounter > 99 ?
'99+' :
widget.conversation.unreadCounter.toString();
// TODO(Unknown): Maybe turn this into an attribute of the widget to prevent calling this
// for every conversation
final screenWidth = MediaQuery.of(context).size.width;
final width = screenWidth - 24 - 70;
final textWidth = screenWidth * 0.6;
final showTimestamp = widget.lastChangeTimestamp != timestampNever;
final showBadge = widget.unreadCount > 0;
final showTimestamp = widget.conversation.lastChangeTimestamp != timestampNever && widget.showTimestamp;
final sentBySelf = widget.conversation.lastMessage?.sender == GetIt.I.get<UIDataService>().ownJid!;
final showBadge = widget.conversation.unreadCounter > 0 && !sentBySelf;
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
AvatarWrapper(
radius: 35,
avatarUrl: widget.avatarUrl,
altText: widget.name,
avatarUrl: widget.conversation.avatarUrl,
altText: widget.conversation.title,
),
Padding(
padding: const EdgeInsets.only(left: 8),
@ -131,8 +156,11 @@ class ConversationsListRowState extends State<ConversationsListRow> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 17),
widget.conversation.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 17,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@ -164,17 +192,18 @@ class ConversationsListRowState extends State<ConversationsListRow> {
maxWidth: textWidth,
child: _buildLastMessageBody(),
),
const Spacer(),
Visibility(
visible: showBadge,
child: const Spacer(),
),
Visibility(
visible: widget.unreadCount > 0,
child: Badge(
badgeContent: Text(badgeText),
badgeColor: bubbleColorSent,
),
),
Visibility(
visible: sentBySelf,
child: _getLastMessageIcon(),
),
],
),
],