Initial reaction support #1

Merged
emersion merged 2 commits from pounce/goguma:reacts into master 2025-01-11 12:56:07 +01:00
Contributor

These commits add IRC support to the model and controller. There is no support yet for viewing or showing reactions. These features will be added in a later PR once consensus on how the features are to be developed has been reached.

These commits add IRC support to the model and controller. There is no support yet for viewing or showing reactions. These features will be added in a later PR once consensus on how the features are to be developed has been reached.
requested review from emersion 2024-10-01 17:44:55 +02:00
pounce force-pushed reacts from a6f60b5460 to dfb3f9a182 2024-10-13 18:16:28 +02:00 Compare
pounce force-pushed reacts from dfb3f9a182 to 767294e17d 2024-10-13 20:29:36 +02:00 Compare
emersion force-pushed reacts from 767294e17d to 019a21fa9b 2024-10-15 23:07:19 +02:00 Compare
pounce force-pushed reacts from 019a21fa9b to 144f617dc7 2024-10-16 02:23:20 +02:00 Compare
emersion reviewed 2024-10-18 18:09:09 +02:00
emersion left a comment
Owner

Here's a first round of comments. The commit messages seem to be mixed up as well.

Thanks!

Here's a first round of comments. The commit messages seem to be mixed up as well. Thanks!
@ -662,8 +662,8 @@ class ClientController {
var typing = msg.tags['+typing'];
if (typing != null && !client.isMyNick(msg.source.name)) {
_bufferList.get(target, network)?.setTyping(msg.source.name, typing == 'active');
break;
Owner

Since there is the client.isMyNick() check this means we prevent some of the typing TAGMSGs from reaching _handleChatMessages() but not all. I think I'd prefer to not have this special case and always pass all of the TAGMSGs to _handleChatMessages().

Since there is the `client.isMyNick()` check this means we prevent some of the typing `TAGMSG`s from reaching `_handleChatMessages()` but not all. I think I'd prefer to not have this special case and always pass all of the `TAGMSG`s to `_handleChatMessages()`.
pounce marked this conversation as resolved
@ -797,3 +795,1 @@
if (buf.messageHistoryLoaded) {
var models = await buildMessageModelList(_db, entries);
buf.addMessages(models, append: !isHistory);
List<IrcMessage> privmsgs = [];
Owner

We could directly build a list of MessageEntry here to remove the need for the privmsgs.map() call below.

We could directly build a list of `MessageEntry` here to remove the need for the `privmsgs.map()` call below.
pounce marked this conversation as resolved
@ -803,0 +816,4 @@
}
// The empty string is the prefix of all strings, and is ordered first.
String t = '';
Owner

Can we move this variable declaration right before we need it, ie. right before the loop on the message entries?

Also I don't think we need to take into account reacts here: reacts don't mark buffers as unread, right?

Can we move this variable declaration right before we need it, ie. right before the loop on the message entries? Also I don't think we need to take into account reacts here: reacts don't mark buffers as unread, right?
pounce marked this conversation as resolved
@ -239,0 +246,4 @@
DateTime? _dateTime;
ReactionEntry({
Owner

Could we maybe use the same signature as the MessageEntry constructor here for consistency?

(Was there a reason for not doing this? For instance, constructing this object requires some tags?)

Could we maybe use the same signature as the `MessageEntry` constructor here for consistency? (Was there a reason for not doing this? For instance, constructing this object requires some tags?)
Author
Contributor

my rationale is that only messages of a particular format can be a ReactionEntry (and we don't really care about the raw message anyway). We already have to do the work of determining whether a message has that format in client.dart, and we would need to duplicate it all here (with a lot of assertions) to get the relevant fields out of an IrcMessage

my rationale is that only messages of a particular format can be a `ReactionEntry` (and we don't really care about the raw message anyway). We already have to do the work of determining whether a message has that format in `client.dart`, and we would need to duplicate it all here (with a lot of assertions) to get the relevant fields out of an `IrcMessage`
pounce marked this conversation as resolved
@ -724,6 +793,65 @@ class DB {
});
}
Future<Map<String, List<ReactionEntry>>> fetchReactionSetByNetworkMsgid(int buffer, List<String> msgids) async {
Owner

Hm, msgids will typically contain 1000 entries. This sounds a bit much... What's the performance impact of this function?

Hm, `msgids` will typically contain 1000 entries. This sounds a bit much... What's the performance impact of this function?
Author
Contributor

This function has almost the same code, and is called in the same context with the same values as fetchMessageSetByNetworkMsgid above, so I was expecting similar performance to existing code

This function has almost the same code, and is called in the same context with the same values as `fetchMessageSetByNetworkMsgid` above, so I was expecting similar performance to existing code
Author
Contributor

as discussed on IRC, this is called with the whole buffer while fetchMessageSetByNetworkMsgid is only called with reply parents

as discussed on IRC, this is called with the whole buffer while `fetchMessageSetByNetworkMsgid` is only called with reply parents
Author
Contributor

I restructured the query to only take two message parameters. This should be a safer way to build the sql query. I'm not sure if it's so much more performant (I didn't check that the query plan is optimized with indices) but it should be better since it's all in the sql engine now.

I restructured the query to only take two message parameters. This should be a safer way to build the sql query. I'm not sure if it's so much more performant (I didn't check that the query plan is optimized with indices) but it should be better since it's all in the sql engine now.
pounce marked this conversation as resolved
@ -727,0 +812,4 @@
return reactions;
}
Future<void> storeOrUpdateReactions(List<ReactionEntry> entries) async {
Owner

In Goguma "store" usually means "upsert", see for instance storeBuffer() and storeMessage(). So I think we can drop OrUpdate here.

In Goguma "store" usually means "upsert", see for instance `storeBuffer()` and `storeMessage()`. So I think we can drop `OrUpdate` here.
pounce marked this conversation as resolved
@ -727,0 +821,4 @@
}
var colNames = <String>[];
var valList = <Object?>[];
entry.toMap().forEach((colName, val) {
Owner

Nit: I slightly prefer for loops over forEach() callbacks.

Nit: I slightly prefer `for` loops over `forEach()` callbacks.
Author
Contributor

this isn't actually forEach from Iterable, since Map doesn't implement Iterable (which is why this function takes two values instead of one). There's a clean forEach version of this which uses dart > v3.0.0 and patterns, which I will change to and we'll see which one is better.

this isn't actually `forEach` from `Iterable`, since `Map` doesn't implement `Iterable` (which is why this function takes _two_ values instead of one). There's a clean forEach version of this which uses dart > v3.0.0 and patterns, which I will change to and we'll see which one is better.
pounce marked this conversation as resolved
@ -727,0 +823,4 @@
var valList = <Object?>[];
entry.toMap().forEach((colName, val) {
colNames.add(colName);
valList.add(val);
Owner

This could maybe be simplified with Map.keys and Map.values. But the current logic is fine by me as well.

This could maybe be simplified with `Map.keys` and `Map.values`. But the current logic is fine by me as well.
pounce marked this conversation as resolved
@ -727,0 +836,4 @@
// In particular, we update reactions only if we see a newer reaction
// from the same user.
//
// UPSERT: https://www.sqlite.org/lang_upsert.html
Owner

I personally don't think we should explain how SQLite works here. Readers unfamiliar with ON CONFLICT … DO can easily read the docs.

I personally don't think we should explain how SQLite works here. Readers unfamiliar with `ON CONFLICT … DO` can easily read the docs.
Author
Contributor

I left the link to the docs. I agree that everything else is superfluous.

I left the link to the docs. I agree that everything else is superfluous.
pounce marked this conversation as resolved
@ -727,0 +841,4 @@
INSERT INTO Reaction (${colNames.join(', ')})
VALUES ($placeholderList)
ON CONFLICT (nick, reply_network_msgid)
DO UPDATE SET text=excluded.text, time=excluded.time
Owner

Nit: missing spaces around =

Nit: missing spaces around `=`
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -393,6 +393,7 @@ class BufferModel extends ChangeNotifier {
String? _lastDeliveredTime;
bool _messageHistoryLoaded = false;
List<MessageModel> _messages = [];
final Map<String, MessageModel> _messagesById = {};
Owner

Could we maybe name this _messagesByNetworkMsgid just to make it clear it's different from database IDs?

Or should we use database IDs as keys instead?

Could we maybe name this `_messagesByNetworkMsgid` just to make it clear it's different from database IDs? Or should we use database IDs as keys instead?
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -492,31 +494,71 @@ class BufferModel extends ChangeNotifier {
notifyListeners();
}
Owner

Nit: stray empty line.

Nit: stray empty line.
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -495,0 +499,4 @@
// We need to make sure that we have a concrete list of MessageModel before
// adding to our two data structures. This ensures that the same object is
// written to both.
msgs = msgs.toList();
Owner

I'm not sure I understand. Is the concern that msgs might yield different objects when it's iterated over twice?

I'm not sure I understand. Is the concern that `msgs` might yield different objects when it's iterated over twice?
Author
Contributor

Yes, this happened in practice and caused some bugs. In particular, some iterables are "consumed" once you iterate over them (with a for loop) while others aren't. If this iterable is consumed in the first call, then it will be empty in the next call.

Yes, this happened in practice and caused some bugs. In particular, some iterables are "consumed" once you iterate over them (with a for loop) while others aren't. If this iterable is consumed in the first call, then it will be empty in the next call.
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -495,0 +516,4 @@
msgs
.map((msg) => MapEntry(msg.entry.networkMsgid, msg))
.where((entry) => entry.key != null)
.map((entry) => MapEntry(entry.key!, entry.value)));
Owner

Would be nice to extract this in a helper function, e.g. _populateMessagesById.

Would be nice to extract this in a helper function, e.g. `_populateMessagesById`.
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -563,3 +606,2 @@
MessageModel({ required this.entry, this.replyTo }) {
assert(entry.id != null);
MessageModel({ required this.entry, this.replyTo, required Iterable<ReactionEntry> reactions}) :
Owner

Nit: missing space before }

Nit: missing space before `}`
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -566,0 +610,4 @@
reactions = [...reactions],
assert(entry.id != null);
void addReaction(ReactionEntry newReact) {
Owner

This should probably be private, since it doesn't notify any listener.

This should probably be private, since it doesn't notify any listener.
Author
Contributor

We might have to use it later to when sending a reaction, but I'm not entirely sure. I can always make it unprivate at that point if need-be.

We might have to use it later to when sending a reaction, but I'm not entirely sure. I can always make it unprivate at that point if need-be.
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -566,0 +612,4 @@
void addReaction(ReactionEntry newReact) {
var oldReact = reactions.cast<ReactionEntry?>()
.singleWhere((react) => react!.nick == newReact.nick, orElse: () => null);
Owner

Do we only allow a single reaction per nick?

Do we only allow a single reaction per nick?
Author
Contributor

Yes, each nick can only react once.

Yes, each nick can only react once.
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -566,0 +615,4 @@
.singleWhere((react) => react!.nick == newReact.nick, orElse: () => null);
if (oldReact != null) {
if(oldReact.dateTime.isAfter(newReact.dateTime)) {
Owner

Nit: missing space before (

Nit: missing space before `(`
pounce marked this conversation as resolved
pounce force-pushed reacts from 144f617dc7 to 9d7cdf8894 2024-10-20 17:01:49 +02:00 Compare
pounce force-pushed reacts from 9d7cdf8894 to c3239abf68 2024-10-20 17:09:39 +02:00 Compare
emersion reviewed 2024-10-24 19:15:30 +02:00
emersion left a comment
Owner

Thanks a lot! Here's a new round of comments, we're getting closer :)

Thanks a lot! Here's a new round of comments, we're getting closer :)
.gitignore Outdated
@ -30,6 +30,7 @@
.pub-cache/
.pub/
/build/
devtools_options.yaml
Owner

Nit: where is this file coming from?

Nit: where is this file coming from?
Author
Contributor

It think it's autogenerated by the flutter debugger. That or some portion of the dart tooling that I'm using. See https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states

Either way since it's mentioned in the official flutter docs, I thought it'd be fine to add to the .gitignore

It think it's autogenerated by the flutter debugger. That or some portion of the dart tooling that I'm using. See https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states Either way since it's mentioned in the official flutter docs, I thought it'd be fine to add to the .gitignore
pounce marked this conversation as resolved
@ -803,0 +829,4 @@
// now we need to update unread counts and bump lastReadTimes
// The empty string is the prefix of all strings, and is ordered first.
String t = '';
Owner

I think the 3 lines above can be reverted to be the same as the previous logic.

I think the 3 lines above can be reverted to be the same as the previous logic.
pounce marked this conversation as resolved
@ -1157,1 +1182,3 @@
}
List<String> parentMsgids = entries
.map((e) => e.msg.tags['+draft/reply'])
.whereType<String>()
Owner

Hm, not a fan of whereType() because it's dangerous wrt. type safety. If this doesn't match what's in the entries then all entries are silently dropped?

Hm, not a fan of `whereType()` because it's dangerous wrt. type safety. If this doesn't match what's in the entries then all entries are silently dropped?
pounce marked this conversation as resolved
@ -1161,0 +1188,4 @@
var parentSet = await db.fetchMessageSetByNetworkMsgid(bufferId, parentMsgids);
// We assume here that `entries` is given in time-sorted order.
Map<String, List<ReactionEntry>> reactionSet = {};
Owner

Nit: this is a map, not a set (ditto for parentSet)

Nit: this is a map, not a set (ditto for `parentSet`)
pounce marked this conversation as resolved
@ -1161,2 +1193,4 @@
return entries.map((entry) {
MessageEntry? replyTo;
List<ReactionEntry> reacts = List.empty();
Owner

Nit: [] can be used instead of List.empty().

Nit: `[]` can be used instead of `List.empty()`.
pounce marked this conversation as resolved
@ -1167,1 +1201,3 @@
return MessageModel(entry: entry, replyTo: replyTo);
var msgid = entry.networkMsgid;
if (msgid != null) {
reacts = reactionSet[msgid] ?? reacts;
Owner

Nit: ?? reacts is unnecessary

Nit: `?? reacts` is unnecessary
Author
Contributor

here reactionSet[msgid] could very easily be null, if msgid doesn't have any reactions. So instead we give it an empty list.

I've condensed this logic into something much simpler with one null check.

here `reactionSet[msgid]` could very easily be null, if `msgid` doesn't have any reactions. So instead we give it an empty list. I've condensed this logic into something much simpler with one null check.
@ -380,0 +424,4 @@
CREATE TABLE Reaction (
id INTEGER PRIMARY KEY,
buffer INTEGER NOT NULL,
nick TEXT NOT NULL,
Owner

I'm kind of wondering if we should really store the nickname as a field, or if we should take a more Message-like approach where the whole raw IRC message is stored in the DB. The reason is that we may want to access other message tags, e.g. account. I'm unsure we'll want to dedup by nickname on the long run.

I'm kind of wondering if we should really store the nickname as a field, or if we should take a more `Message`-like approach where the whole raw IRC message is stored in the DB. The reason is that we may want to access other message tags, e.g. `account`. I'm unsure we'll want to dedup by nickname on the long run.
pounce marked this conversation as resolved
@ -727,0 +798,4 @@
SELECT *
FROM Reaction
WHERE buffer = ?
AND reply_network_msgid IN (
Owner

Style nit: we do not align SQL keywords (we don't align anything in general)

Style nit: we do not align SQL keywords (we don't align anything in general)
pounce marked this conversation as resolved
lib/models.dart Outdated
@ -563,3 +602,2 @@
MessageModel({ required this.entry, this.replyTo }) {
assert(entry.id != null);
MessageModel({ required this.entry, this.replyTo, required Iterable<ReactionEntry> reactions }) :
Owner

Nit: maybe reactions could be optional like replyTo

Nit: maybe `reactions` could be optional like `replyTo`
pounce marked this conversation as resolved
pubspec.yaml Outdated
@ -10,3 +10,3 @@
environment:
sdk: '>=2.19.0 <3.0.0'
sdk: '>=3.0.0 <4.0.0'
Owner

Hm, what caused this?

Hm, what caused this?
pounce marked this conversation as resolved
pounce force-pushed reacts from c3239abf68 to 5246c1b4a0 2024-11-10 18:07:53 +01:00 Compare
pounce force-pushed reacts from 5246c1b4a0 to cd83964f67 2024-11-10 19:37:29 +01:00 Compare
pounce force-pushed reacts from cd83964f67 to 04ee3a256d 2024-11-10 19:43:28 +01:00 Compare
emersion reviewed 2024-11-22 16:12:02 +01:00
@ -468,0 +512,4 @@
id INTEGER PRIMARY KEY,
buffer INTEGER NOT NULL,
nick TEXT NOT NULL,
text TEXT NOT NULL,
Owner

Seems like this is unused.

Seems like this is unused.
pounce marked this conversation as resolved
pounce force-pushed reacts from 04ee3a256d to 5716ed4e55 2024-11-28 01:37:26 +01:00 Compare
pounce force-pushed reacts from 5716ed4e55 to 04e20bb9c2 2024-11-28 01:40:21 +01:00 Compare
emersion force-pushed reacts from 04e20bb9c2 to f77a909ff5 2025-01-09 20:39:36 +01:00 Compare
emersion force-pushed reacts from f77a909ff5 to d8d8afecf6 2025-01-11 12:54:09 +01:00 Compare
Owner

I've made a few changes to the implementation:

  • Allow multiple reactions from the same nick. I don't think it's good UX to only allow a single reaction per nick to a message, I think this is better addressed with REDACT or a new unreact tag.
  • Return an unmodifiable list view in the MessageModel.reactions getter.
  • Various other cleanups.

Thanks!

I've made a few changes to the implementation: - Allow multiple reactions from the same nick. I don't think it's good UX to only allow a single reaction per nick to a message, I think this is better addressed with REDACT or a new unreact tag. - Return an unmodifiable list view in the `MessageModel.reactions` getter. - Various other cleanups. Thanks!
emersion merged commit d8d8afecf6 into master 2025-01-11 12:56:07 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: emersion/goguma#1
No description provided.