xmpp: RECEIVE MESSAGES!

This commit is contained in:
PapaTutuWawa 2022-01-01 21:24:27 +01:00
parent f22d042255
commit 1acc2630b4
17 changed files with 250 additions and 113 deletions

View File

@ -3,7 +3,7 @@ import "package:moxxyv2/isar.g.dart";
@Collection()
@Name("Conversation")
class Conversation {
class DBConversation {
int? id;
@Index(caseSensitive: false)

17
lib/db/message.dart Normal file
View File

@ -0,0 +1,17 @@
import "package:isar/isar.dart";
import "package:moxxyv2/isar.g.dart";
@Collection()
@Name("Message")
class DBMessage {
int? id;
@Index(caseSensitive: false)
late String from;
late int timestamp;
late String body;
late bool sent;
}

View File

@ -21,10 +21,18 @@ String padInt(int i) {
* returned true.
*/
bool listContains<T>(List<T> list, bool Function(T element) test) {
return firstWhereOrNull<T>(list, test) != null;
}
/*
* A wrapper around List<T>.firstWhere that does not throw but instead just
* return null if test never returned true
*/
T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
try {
return list.firstWhere(test) != null;
return list.firstWhere(test);
} catch(e) {
return false;
return null;
}
}

View File

@ -3,15 +3,17 @@ class Message {
final int timestamp; // NOTE: Milliseconds since Epoch
final String from;
final bool sent;
final int id; // Database ID
const Message({ required this.from, required this.body, required this.timestamp, required this.sent });
const Message({ required this.from, required this.body, required this.timestamp, required this.sent, required this.id });
Message copyWith({ String? from, String? body, int? timestamp }) {
return Message(
from: from ?? this.from,
body: body ?? this.body,
timestamp: timestamp ?? this.timestamp,
sent: this.sent
sent: this.sent,
id: this.id
);
}
}

View File

@ -1,3 +1,6 @@
import "package:moxxyv2/models/message.dart";
import "package:moxxyv2/xmpp/jid.dart";
class SetShowSendButtonAction {
final bool show;
@ -20,6 +23,21 @@ class SendMessageAction {
SendMessageAction({ required this.from, required this.body, required this.timestamp, required this.jid, required this.cid });
}
class ReceiveMessageAction {
final String body;
final int timestamp;
final FullJID from;
final String jid;
ReceiveMessageAction({ required this.from, required this.body, required this.timestamp, required this.jid });
}
class AddMessageAction {
final Message message;
AddMessageAction({ required this.message });
}
class CloseConversationAction {
final String jid;
final int id;

View File

@ -5,24 +5,14 @@ import "package:moxxyv2/redux/conversation/state.dart";
import "package:moxxyv2/redux/conversation/actions.dart";
HashMap<String, List<Message>> messageReducer(HashMap<String, List<Message>> state, dynamic action) {
if (action is SendMessageAction) {
HashMap<String, List<Message>> map = HashMap<String, List<Message>>()..addAll(state);
Message msg = Message(
from: action.from,
body: action.body,
timestamp: action.timestamp,
sent: true
);
String jid = action.jid;
if (!map.containsKey(jid)) {
map[jid] = [ msg ];
return map;
if (action is AddMessageAction) {
if (!state.containsKey(action.message.from)) {
state[action.message.from] = List.from([ action.message ]);
} else {
state[action.message.from] = state[action.message.from]!..add(action.message);
}
map[jid]!.add(msg);
return map;
return state;
}
return state;

View File

@ -1,16 +1,13 @@
import "dart:collection";
import "package:moxxyv2/models/conversation.dart";
class AddConversationAction {
final String title;
final String lastMessageBody;
final String avatarUrl;
final String jid;
final int id;
final int unreadCounter;
final List<String> sharedMediaPaths;
final int lastChangeTimestamp;
final bool triggeredByDatabase;
final bool open;
Conversation conversation;
AddConversationAction({ required this.title, required this.lastMessageBody, required this.avatarUrl, required this.jid, required this.sharedMediaPaths, required this.lastChangeTimestamp, required this.id, this.unreadCounter = 0, this.triggeredByDatabase = false, required this.open });
AddConversationAction({ required this.conversation });
}
class UpdateConversationAction {
Conversation conversation;
UpdateConversationAction({ required this.conversation });
}

View File

@ -2,29 +2,13 @@ import "package:moxxyv2/redux/state.dart";
import "package:moxxyv2/redux/conversations/actions.dart";
import "package:moxxyv2/redux/conversation/actions.dart";
import "package:moxxyv2/repositories/conversation.dart";
import "package:moxxyv2/models/conversation.dart";
import "package:redux/redux.dart";
import "package:flutter_redux_navigation/flutter_redux_navigation.dart";
import "package:get_it/get_it.dart";
void conversationsMiddleware(Store<MoxxyState> store, action, NextDispatcher next) {
var repo = GetIt.I.get<DatabaseRepository>();
if (action is AddConversationAction && !action.triggeredByDatabase) {
if (repo.hasConversation(action.id)) {
// TODO
} else {
repo.addConversationFromAction(action);
}
} else if (action is SendMessageAction) {
if (repo.hasConversation(action.cid)) {
repo.updateConversation(id: action.cid, lastMessageBody: action.body, lastChangeTimestamp: action.timestamp);
} else {
// TODO
}
} else if (action is CloseConversationAction) {
store.dispatch(NavigateToAction.replace("/conversations"));
}
void conversationsMiddleware(Store<MoxxyState> store, action, NextDispatcher next) async {
next(action);
}

View File

@ -5,30 +5,16 @@ import "package:moxxyv2/redux/conversation/actions.dart";
List<Conversation> conversationReducer(List<Conversation> state, dynamic action) {
if (action is AddConversationAction) {
state.add(Conversation(
title: action.title,
lastMessageBody: action.lastMessageBody,
avatarUrl: action.avatarUrl,
jid: action.jid,
// TODO: Correct?
unreadCounter: 0,
sharedMediaPaths: action.sharedMediaPaths,
lastChangeTimestamp: action.lastChangeTimestamp,
open: action.open,
id: action.id
));
} else if (action is SendMessageAction) {
return state.map((element) {
if (element.jid == action.jid) {
return element.copyWith(lastMessageBody: action.body, lastChangeTimestamp: action.timestamp);
return state..add(action.conversation);
} else if (action is UpdateConversationAction) {
return state.map((c) {
if (c.id == action.conversation.id) {
return action.conversation;
}
return element;
return c;
}).toList();
} else if (action is CloseConversationAction) {
// TODO: Yikes.
return state.map((element) => element.jid == action.jid ? element.copyWith(open: false) : element).toList().where((element) => element.open).toList();
}
return state;
}

View File

@ -0,0 +1,5 @@
class LoadMessagesAction {
final String jid;
LoadMessagesAction({ required this.jid });
}

View File

@ -0,0 +1,65 @@
import "package:moxxyv2/redux/state.dart";
import "package:moxxyv2/redux/conversations/actions.dart";
import "package:moxxyv2/redux/conversation/actions.dart";
import "package:moxxyv2/repositories/conversation.dart";
import "package:moxxyv2/models/conversation.dart";
import "package:moxxyv2/models/message.dart";
import "package:moxxyv2/redux/messages/actions.dart";
import "package:moxxyv2/helpers.dart";
import "package:redux/redux.dart";
import "package:flutter_redux_navigation/flutter_redux_navigation.dart";
import "package:get_it/get_it.dart";
void messageMiddleware(Store<MoxxyState> store, action, NextDispatcher next) async {
if (action is ReceiveMessageAction) {
// TODO: Check if the conversation already exists
final repo = GetIt.I.get<DatabaseRepository>();
final now = DateTime.now().millisecondsSinceEpoch;
final bareJidString = action.from.toBare().toString();
final message = await repo.addMessageFromData(
action.body,
now,
bareJidString,
false
);
final existantConversation = firstWhereOrNull(store.state.conversations, (Conversation c) => c.jid == bareJidString);
if (existantConversation == null) {
final conversation = await repo.addConversationFromData(
action.from.local,
action.body,
"",
bareJidString,
1,
now,
[],
true
);
repo.loadedConversations.add(bareJidString);
store.dispatch(AddConversationAction(conversation: conversation));
} else {
await repo.updateConversation(
id: existantConversation.id,
lastMessageBody: action.body,
lastChangeTimestamp: now,
unreadCounter: existantConversation.unreadCounter + 1
);
store.dispatch(UpdateConversationAction(
conversation: existantConversation.copyWith(
lastMessageBody: action.body,
lastChangeTimestamp: now,
unreadCounter: existantConversation.unreadCounter + 1
)
));
}
store.dispatch(AddMessageAction(message: message));
} else if (action is LoadMessagesAction) {
GetIt.I.get<DatabaseRepository>().loadMessagesForJid(action.jid);
}
next(action);
}

View File

@ -1,9 +1,12 @@
import "dart:collection";
import "package:moxxyv2/db/conversation.dart" as db;
import "package:moxxyv2/models/conversation.dart" as model;
import "package:moxxyv2/db/conversation.dart";
import "package:moxxyv2/db/message.dart";
import "package:moxxyv2/models/conversation.dart";
import "package:moxxyv2/models/message.dart";
import "package:moxxyv2/redux/state.dart";
import "package:moxxyv2/redux/conversations/actions.dart";
import "package:moxxyv2/redux/conversation/actions.dart";
import "package:isar/isar.dart";
import "package:redux/redux.dart";
@ -16,16 +19,18 @@ class DatabaseRepository {
final Isar isar;
final Store<MoxxyState> store;
final HashMap<int, db.Conversation> _cache = HashMap();
final HashMap<int, DBConversation> _cache = HashMap();
final List<String> loadedConversations = List.empty(growable: true);
DatabaseRepository({ required this.isar, required this.store });
Future<void> loadConversations() async {
var conversations = await this.isar.conversations.where().findAll();
var conversations = await this.isar.dBConversations.where().findAll();
conversations.forEach((c) {
this._cache[c.id!] = c;
this.store.dispatch(AddConversationAction(
conversation: Conversation(
id: c.id!,
title: c.title,
jid: c.jid,
@ -34,19 +39,32 @@ class DatabaseRepository {
unreadCounter: c.unreadCounter,
lastChangeTimestamp: c.lastChangeTimestamp,
sharedMediaPaths: [],
open: c.open,
triggeredByDatabase: true
open: c.open
)
));
}
);
}
Future<void> loadMessagesForJid(String jid) async {
final messages = await this.isar.dBMessages.where().fromEqualTo(jid).findAll();
this.loadedConversations.add(jid);
messages.forEach((m) => this.store.dispatch(AddMessageAction(message: Message(
from: m.from,
body: m.body,
timestamp: m.timestamp,
sent: m.sent,
id: m.id!
))));
}
// TODO
bool hasConversation(int id) {
return this._cache.containsKey(id);
}
Future<void> updateConversation({ required int id, String? lastMessageBody, int? lastChangeTimestamp, bool? open }) async {
Future<void> updateConversation({ required int id, String? lastMessageBody, int? lastChangeTimestamp, bool? open, int? unreadCounter }) async {
print("updateConversation");
final c = this._cache[id]!;
@ -59,40 +77,64 @@ class DatabaseRepository {
if (open != null) {
c.open = open;
}
if (unreadCounter != null) {
c.unreadCounter = unreadCounter;
}
await this.isar.writeTxn((isar) async {
await isar.conversations.put(c);
await isar.dBConversations.put(c);
print("DONE");
});
}
Future<void> addConversationFromAction(AddConversationAction action) async {
print("addConversationFromACtion");
final c = db.Conversation()
..jid = action.jid
..title = action.title
..avatarUrl = action.avatarUrl
..lastChangeTimestamp = action.lastChangeTimestamp
..unreadCounter = action.unreadCounter
..lastMessageBody = action.lastMessageBody
..open = action.open;
Future<Conversation> addConversationFromData(String title, String lastMessageBody, String avatarUrl, String jid, int unreadCounter, int lastChangeTimestamp, List<String> sharedMediaPaths, bool open) async {
print("addConversationFromAction");
final c = DBConversation()
..jid = jid
..title = title
..avatarUrl = avatarUrl
..lastChangeTimestamp = lastChangeTimestamp
..unreadCounter = unreadCounter
..lastMessageBody = lastMessageBody
..open = open;
await this.isar.writeTxn((isar) async {
await isar.conversations.put(c);
await isar.dBConversations.put(c);
print("DONE");
});
this._cache[c.id!] = c;
return Conversation(
title: title,
lastMessageBody: lastMessageBody,
avatarUrl: avatarUrl,
jid: jid,
id: c.id!,
unreadCounter: unreadCounter,
lastChangeTimestamp: lastChangeTimestamp,
sharedMediaPaths: sharedMediaPaths,
open: open
);
}
Future<void> addConversation(model.Conversation conversation) async {
final c = db.Conversation()
..jid = conversation.jid
..title = conversation.title
..avatarUrl = conversation.avatarUrl
..lastChangeTimestamp = conversation.lastChangeTimestamp
..unreadCounter = conversation.unreadCounter
..lastMessageBody = conversation.lastMessageBody
..open = conversation.open;
Future<Message> addMessageFromData(String body, int timestamp, String from, bool sent) async {
print("addMessageFromData");
final m = DBMessage()
..from = from
..timestamp = timestamp
..body = body
..sent = sent;
await this.isar.writeTxn((isar) async {
await isar.conversations.put(c);
await isar.dBMessages.put(m);
print("DONE");
});
return Message(
body: body,
from: from,
timestamp: timestamp,
sent: sent,
id: m.id!
);
}
}

View File

@ -13,10 +13,13 @@ import "package:moxxyv2/ui/pages/profile/profile.dart";
import "package:moxxyv2/ui/pages/conversation/arguments.dart";
import "package:moxxyv2/ui/constants.dart";
import "package:moxxyv2/ui/helpers.dart";
import "package:moxxyv2/repositories/conversation.dart";
import "package:moxxyv2/redux/messages/actions.dart";
import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:flutter_redux/flutter_redux.dart";
import "package:redux/redux.dart";
import "package:get_it/get_it.dart";
typedef SendMessageFunction = void Function(String body);
@ -50,8 +53,9 @@ class _MessageListViewModel {
final void Function(bool scrollToEndButton) setShowScrollToEndButton;
final bool showScrollToEndButton;
final void Function() closeChat;
_MessageListViewModel({ required this.conversation, required this.showSendButton, required this.sendMessage, required this.setShowSendButton, required this.showScrollToEndButton, required this.setShowScrollToEndButton, required this.closeChat });
final void Function(String) loadMessages;
_MessageListViewModel({ required this.conversation, required this.showSendButton, required this.sendMessage, required this.setShowSendButton, required this.showScrollToEndButton, required this.setShowScrollToEndButton, required this.closeChat, required this.loadMessages });
}
class _ListViewWrapperViewModel {
@ -182,6 +186,7 @@ class ConversationPage extends StatelessWidget {
jid: jid,
id: conversation.id
)),
loadMessages: (jid) => store.dispatch(LoadMessagesAction(jid: jid)),
sendMessage: (body) => store.dispatch(
// TODO
SendMessageAction(
@ -195,6 +200,11 @@ class ConversationPage extends StatelessWidget {
);
},
builder: (context, viewModel) {
// TODO: Handle this in a middleware
if (GetIt.I.get<DatabaseRepository>().loadedConversations.indexOf(jid) == -1) {
viewModel.loadMessages(jid);
}
return Scaffold(
appBar: BorderlessTopbar.avatarAndName(
avatar: AvatarWrapper(

View File

@ -1,8 +1,10 @@
import "package:moxxyv2/xmpp/jid.dart";
abstract class XmppEvent {}
class MessageEvent extends XmppEvent {
final String body;
final String fromJid;
final FullJID fromJid;
final String sid;
MessageEvent({ required this.body, required this.fromJid, required this.sid });

View File

@ -30,7 +30,17 @@ class BareJID extends JID {
class FullJID extends JID {
FullJID({ required String local, required String domain, required String resource }) : super(local: local, domain: domain, resource: resource);
BareJID toBare() {
return BareJID(local: this.local, domain: this.domain);
}
static FullJID fromString(String fullJid) {
final jidParts = fullJid.split("@");
final other = jidParts[1].split("/");
return FullJID(local: jidParts[0], domain: other[0], resource: other[1]);
}
String toString() {
return "${this.local}@${this.domain}/${this.resource}";
}

View File

@ -1,6 +1,7 @@
import "package:moxxyv2/xmpp/stanzas/stanza.dart";
import "package:moxxyv2/xmpp/connection.dart";
import "package:moxxyv2/xmpp/events.dart";
import "package:moxxyv2/xmpp/jid.dart";
bool handleMessageStanza(XmppConnection conn, Stanza stanza) {
final body = stanza.firstTag("body");
@ -8,7 +9,7 @@ bool handleMessageStanza(XmppConnection conn, Stanza stanza) {
conn.sendEvent(MessageEvent(
body: body.innerText(),
fromJid: stanza.attributes["from"]!,
fromJid: FullJID.fromString(stanza.attributes["from"]!),
sid: stanza.attributes["id"]!
));

View File

@ -18,12 +18,12 @@ void main() {
});
});
group("listContains", () {
group("firstWhereOrNull", () {
test("[] should not contain 1", () {
expect(listContains<int>([], (int element) => element == 1), false);
expect(firstWhereOrNull<int>([], (int element) => element == 1), null);
});
test("[1, 2, 3] should contain 2", () {
expect(listContains([ 1, 2, 3 ], (int element) => element == 2), true);
expect(firstWhereOrNull([ 1, 2, 3 ], (int element) => element == 2), 2);
});
});