~emersion/goguma-dev

Add emoji reactions v2 SUPERSEDED

Calvin Lee: 5
 model: add support for reactions
 irc: add support for reactions
 refactor view
 view: add reactions
 send reactions

 21 files changed, 1327 insertions(+), 593 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~emersion/goguma-dev/patches/46663/mbox | git am -3
Learn more about email & git

[PATCH v2 1/5] model: add support for reactions Export this patch

This commit adds reactions to the model and database.
---
 lib/client_controller.dart |  28 +++++---
 lib/database.dart          | 131 +++++++++++++++++++++++++++++++++++++
 lib/models.dart            |   8 ++-
 3 files changed, 155 insertions(+), 12 deletions(-)

diff --git a/lib/client_controller.dart b/lib/client_controller.dart
index bcadf11..025abea 100644
--- a/lib/client_controller.dart
+++ b/lib/client_controller.dart
@@ -1096,22 +1096,30 @@ Future<Iterable<MessageModel>> buildMessageModelList(DB db, List<MessageEntry> e
		return [];
	}

	List<String> msgids = [];
	for (var entry in entries) {
		var parentMsgid = entry.msg.tags['+draft/reply'];
		if (parentMsgid != null) {
			msgids.add(parentMsgid);
		}
	}
	List<String> parentMsgids = entries
		.map((e) => e.msg.tags['+draft/reply'])
		.whereType<String>()
		.toList();

	List<String> msgids = entries
		.map((e) => e.networkMsgid)
		.whereType<String>()
		.toList();

	var bufferId = entries.first.buffer;
	var parents = await db.fetchMessageSetByNetworkMsgid(bufferId, msgids);
	var parentSet = await db.fetchMessageSetByNetworkMsgid(bufferId, parentMsgids);
	var reactionSet = await db.fetchReactionSetByNetworkMsgid(bufferId, msgids);
	return entries.map((entry) {
		MessageEntry? replyTo;
		List<ReactionEntry> reacts = List.empty();
		var parentMsgid = entry.msg.tags['+draft/reply'];
		if (parentMsgid != null) {
			replyTo = parents[parentMsgid];
			replyTo = parentSet[parentMsgid];
		}
		var msgid = entry.networkMsgid;
		if (msgid != null) {
			reacts = reactionSet[msgid] ?? reacts;
		}
		return MessageModel(entry: entry, replyTo: replyTo);
		return MessageModel(entry: entry, replyTo: replyTo, reactions: reacts);
	});
}
diff --git a/lib/database.dart b/lib/database.dart
index 48f0d60..6c28f58 100644
--- a/lib/database.dart
+++ b/lib/database.dart
@@ -232,6 +232,49 @@ class MessageEntry {
	}
}

class ReactionEntry {
	int? id;
	final int buffer;
	final String nick;
	final String text;
	final String time;
	final String? inReplyToMsgid;

	DateTime? _dateTime;

	ReactionEntry({
		required this.buffer,
		required this.nick,
		required this.text,
		required this.time,
		this.inReplyToMsgid,
	});

	Map<String, Object?> toMap() {
		return <String, Object?>{
			'id': id,
			'buffer': buffer,
			'nick': nick,
			'text': text,
			'time': time,
			'reply_network_msgid': inReplyToMsgid,
		};
	}

	ReactionEntry.fromMap(Map<String, dynamic> m) :
		id = m['id'] as int,
		buffer = m['buffer'] as int,
		nick = m['nick'] as String,
		text = m['text'] as String,
		time = m['time'] as String,
		inReplyToMsgid = m['reply_network_msgid'] as String?;

	DateTime get dateTime {
		_dateTime ??= DateTime.parse(time);
		return _dateTime!;
	}
}

class WebPushSubscriptionEntry {
	int? id;
	final int network;
@@ -372,6 +415,19 @@ const _schema = [
			UNIQUE(name, network)
		)
	''',
	'''
		CREATE TABLE Reaction (
			id INTEGER PRIMARY KEY,
			buffer INTEGER NOT NULL,
			nick TEXT NOT NULL,
			text TEXT NOT NULL,
			time TEXT NOT NULL,
			reply_network_msgid TEXT NOT NULL,
			UNIQUE(nick, reply_network_msgid),
			FOREIGN KEY (buffer) REFERENCES Buffer(id) ON DELETE CASCADE
		)
	''',
	'CREATE INDEX index_reaction_reply_network_msgid on Reaction(reply_network_msgid)',
	'''
		CREATE TABLE Message (
			id INTEGER PRIMARY KEY,
@@ -459,6 +515,19 @@ const _migrations = [
	'CREATE INDEX index_message_network_msgid on Message(network_msgid)',
	'ALTER TABLE LinkPreview ADD COLUMN image_url TEXT',
	'ALTER TABLE Network ADD COLUMN last_delivered_time TEXT',
	'''
		CREATE TABLE Reaction (
			id INTEGER PRIMARY KEY,
			buffer INTEGER NOT NULL,
			nick TEXT NOT NULL,
			text TEXT NOT NULL,
			time TEXT NOT NULL,
			reply_network_msgid TEXT NOT NULL,
			UNIQUE(nick, reply_network_msgid),
			FOREIGN KEY (buffer) REFERENCES Buffer(id) ON DELETE CASCADE
		)
	''',
	'CREATE INDEX index_reaction_reply_network_msgid on Reaction(reply_network_msgid)',
];

class DB {
@@ -718,6 +787,68 @@ class DB {
		});
	}

	Future<Map<String, List<ReactionEntry>>> fetchReactionSetByNetworkMsgid(int buffer, List<String> msgids) async {
		var inList = List.filled(msgids.length, '?').join(', ');
		var entries = await _db.rawQuery('''
				SELECT *
				FROM Reaction
				WHERE buffer = ? AND reply_network_msgid IN ($inList)
		''', <Object>[buffer, ...msgids]);
		Map<String, List<ReactionEntry>> reactions = {};
		for (var m in entries) {
			var entry = ReactionEntry.fromMap(m);
			reactions.update(
				entry.inReplyToMsgid!,
				(l) => l..add(entry),
				ifAbsent: () => [entry],
			);
		}
		return reactions;
	}

	Future<void> storeOrUpdateReactions(List<ReactionEntry> entries) async {
		await _db.transaction((txn) async {
			await Future.forEach(entries, (entry) async {
				if (entry.inReplyToMsgid == null) {
					return;
				}
				if (entry.id != null) {
					await _updateById('Reaction', entry.toMap(), executor: txn);
					return;
				}
				var colNames = <String>[];
				var valList = <Object?>[];
				entry.toMap().forEach((colName, val) {
					colNames.add(colName);
					valList.add(val);
				});
				var placeholderList = List.filled(valList.length, '?').join(', ');
			  // Our table has a `UNIQUE(nick, reply_network_msgid)` constraint
				// which enforces that a user can only leave one reply per message.
				//
				// If we blindly inserted a reaction, we could easily break this constraint.
				// Instead, we utilize a SQLite [UPSERT] command in order to conditionally update the value
				// if the constraint would be violated.
				//
				// In particular, we update reactions only if we see a newer reaction
				// from the same user.
				//
				// UPSERT: https://www.sqlite.org/lang_upsert.html
				var id = await txn.rawInsert('''
					INSERT INTO Reaction (${colNames.join(', ')})
					VALUES ($placeholderList)
					ON CONFLICT (nick, reply_network_msgid)
					DO UPDATE SET text=excluded.text, time=excluded.time
					WHERE excluded.time > Reaction.time;
				''', valList);
				if (id != 0) {
					// We have inserted a value
					entry.id = id;
				}
			});
		});
	}

	Future<List<WebPushSubscriptionEntry>> listWebPushSubscriptions() async {
		var entries = await _db.rawQuery('''
			SELECT * FROM WebPushSubscription
diff --git a/lib/models.dart b/lib/models.dart
index 73f071c..d551e84 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -553,9 +553,13 @@ int _compareMessageModels(MessageModel a, MessageModel b) {
class MessageModel {
	final MessageEntry entry;
	final MessageEntry? replyTo;
	final List<ReactionEntry> reactions;

	MessageModel({ required this.entry, this.replyTo }) {
		assert(entry.id != null);
	MessageModel({ required this.entry, this.replyTo, required Iterable<ReactionEntry> reactions}) :
			// Our reaction list needs to be mutable. This is why we spread
			// instead of taking a list and storing it.
			reactions = [...reactions],
			assert(entry.id != null);
	}

	int get id => entry.id!;
-- 
2.42.1

[PATCH v2 2/5] irc: add support for reactions Export this patch

This commit adds support for reactions in the IRC client, and routes
them into the model. This commit does not yet allow for viewing
reactions, but it should store them correctly in the model for doing so.

This commit also fixes a bug where TAGMSGs (and other non-
PRIVMSG/NOTICE) commands were stored in the model. Instead, the model
now only accepts `PrivmsgLike` objects, which contain a PRIVMSG or
NOTICE. This should stop related bugs from happening in the future.
---
 lib/client.dart            | 60 +++++++++++++++++++++++++++++++----
 lib/client_controller.dart | 49 +++++++++++++++++++++++++----
 lib/database.dart          |  9 +++---
 lib/models.dart            | 64 +++++++++++++++++++++++++++++++++++---
 lib/push.dart              | 12 ++++---
 lib/widget/composer.dart   | 14 ++++-----
 6 files changed, 176 insertions(+), 32 deletions(-)

diff --git a/lib/client.dart b/lib/client.dart
index 5bad929..212d54e 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -761,10 +761,7 @@ class Client {
		}
	}

	Future<IrcMessage> sendTextMessage(IrcMessage req) async {
		assert(req.cmd == 'PRIVMSG' || req.cmd == 'NOTICE');
		assert(req.params.length == 2);

	Future<PrivmsgLike> sendTextMessage(PrivmsgLike req) async {
		if (caps.enabled.contains('echo-message')) {
			// Assume the first echo-message we get is the one we're waiting
			// for. Rely on labeled-response to improve this assumption's
@@ -782,7 +779,7 @@ class Client {
				_pendingTextMsgs[pendingKey] = skip + 1;
			}

			IrcMessage? echo;
			PrivmsgLike? echo;
			try {
				await _roundtripMessage(req, (reply) {
					bool match;
@@ -810,7 +807,8 @@ class Client {
					if (reply.cmd != req.cmd) {
						throw IrcException(reply);
					} else {
						echo = reply;
						// We know `echo` is PrivmsgLike because the cmd matches
						echo = PrivmsgLike(reply);
						return true;
					}
				});
@@ -1204,6 +1202,56 @@ class ClientMessage extends IrcMessage {
		return null;
	}
}
abstract class PrivmsgLike extends IrcMessage {
	final String target;
	final String messageContent;

	PrivmsgLike._(IrcMessage msg) :
			assert(msg.params.length == 2),
			target = msg.params[0],
			messageContent = msg.params[1],
			super(msg.cmd, msg.params, tags: msg.tags, source: msg.source);

	factory PrivmsgLike(IrcMessage msg) {
		if (msg.cmd == 'PRIVMSG') {
			return Privmsg.from(msg);
		} else if (msg.cmd == 'NOTICE') {
			return Notice.from(msg);
		} else {
			throw ArgumentError.value(msg, 'msg', 'Message must be a PRIVMSG or NOTICE');
		}
	}

	@override
	PrivmsgLike copyWith({
		IrcSource? source,
		Map<String, String?>? tags,
	}) {
		return PrivmsgLike(super.copyWith(source: source, tags: tags));
	}

	static PrivmsgLike parse(String s) {
		return PrivmsgLike(IrcMessage.parse(s));
	}
}

class Privmsg extends PrivmsgLike {
	Privmsg.from(super.msg) :
			assert(msg.cmd == 'PRIVMSG'),
			super._();

	Privmsg(String target, String text, { Map<String, String?> tags = const {} }) :
		this.from(IrcMessage('PRIVMSG', [target, text], tags: tags));
}

class Notice extends PrivmsgLike {
	Notice.from(super.msg) :
			assert(msg.cmd == 'NOTICE'),
			super._();

	Notice(String target, String text) :
		this.from(IrcMessage('NOTICE', [target, text]));
}

class ClientEndOfNames extends ClientMessage {
	final NamesReply names;
diff --git a/lib/client_controller.dart b/lib/client_controller.dart
index 025abea..97ddc61 100644
--- a/lib/client_controller.dart
+++ b/lib/client_controller.dart
@@ -43,6 +43,7 @@ class ClientException extends IrcException {
}

class ClientNotice {
	// TODO should this be a List<Notice>?
	final List<ClientMessage> msgs;
	final String target;
	final Client client;
@@ -609,8 +610,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;
				}
				break;
			}
			return _handleChatMessages(target, [msg]);
		case 'INVITE':
@@ -739,16 +740,47 @@ class ClientController {
			return;
		}

		var entries = messages.map((msg) => MessageEntry(msg, buf!.id)).toList();
		await _db.storeMessages(entries);
		List<PrivmsgLike> privmsgs = [];
		List<ReactionEntry> reactions = [];
		for (var msg in messages) {
			if (msg.cmd == 'NOTICE' || msg.cmd == 'PRIVMSG') {
				privmsgs.add(PrivmsgLike(msg));
			} else if (msg.cmd == 'TAGMSG') {
				var reply = msg.tags['+draft/reply'];
				var react = msg.tags['+draft/react'];
				if (reply != null && react != null) {
					reactions.add(ReactionEntry(
						nick: msg.source.name,
						buffer: buf.id,
						text: react,
						inReplyToMsgid: reply,
						time: msg.tags['time'] ?? formatIrcTime(DateTime.now()),
					));
				}
			}
		}

		var messageEntries = privmsgs.map((msg) => MessageEntry(msg, buf!.id)).toList();
		String t;
		if (!messageEntries.isEmpty) {
			t = messageEntries.first.time;
			await _db.storeMessages(messageEntries);
		} else if (!reactions.isEmpty) {
			t = reactions.first.time;
			await _db.storeOrUpdateReactions(reactions);
		} else {
			return;
		}
		if (buf.messageHistoryLoaded) {
			var models = await buildMessageModelList(_db, entries);
			var models = await buildMessageModelList(_db, messageEntries);
			buf.addMessages(models, append: !isHistory);
			buf.addReactions(reactions);
		}

		String t = entries.first.time;
		// now we need to update unread counts and bump lastReadTimes
		List<MessageEntry> unread = [];
		for (var entry in entries) {
		for (var entry in messageEntries) {
			// so get the newest time
			if (entry.time.compareTo(t) > 0) {
				t = entry.time;
			}
@@ -757,6 +789,11 @@ class ClientController {
				unread.add(entry);
			}
		}
		for (var entry in reactions) {
			if (entry.time.compareTo(t) > 0) {
				t = entry.time;
			}
		}

		if (!buf.focused) {
			buf.unreadCount += unread.length;
diff --git a/lib/database.dart b/lib/database.dart
index 6c28f58..a30d3b9 100644
--- a/lib/database.dart
+++ b/lib/database.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'dart:typed_data';

import 'package:flutter/widgets.dart';
import 'package:goguma/client.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart' show sqfliteFfiInit, databaseFactoryFfi;
@@ -195,7 +196,7 @@ class MessageEntry {
	final int buffer;
	final String raw;

	IrcMessage? _msg;
	PrivmsgLike? _msg;
	DateTime? _dateTime;

	Map<String, Object?> toMap() {
@@ -208,7 +209,7 @@ class MessageEntry {
		};
	}

	MessageEntry(IrcMessage msg, this.buffer) :
	MessageEntry(PrivmsgLike msg, this.buffer) :
		time = msg.tags['time'] ?? formatIrcTime(DateTime.now()),
		networkMsgid = msg.tags['msgid'],
		raw = msg.toString(),
@@ -221,8 +222,8 @@ class MessageEntry {
		buffer = m['buffer'] as int,
		raw = m['raw'] as String;

	IrcMessage get msg {
		_msg ??= IrcMessage.parse(raw);
	PrivmsgLike get msg {
		_msg ??= PrivmsgLike.parse(raw);
		return _msg!;
	}

diff --git a/lib/models.dart b/lib/models.dart
index d551e84..11d94fc 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -386,6 +386,7 @@ class BufferModel extends ChangeNotifier {
	String? _lastDeliveredTime;
	bool _messageHistoryLoaded = false;
	List<MessageModel> _messages = [];
	final Map<String, MessageModel> _messagesById = {};
	final Map<String, Timer> _typing = {};

	// Kept in sync by BufferPageState
@@ -401,6 +402,7 @@ class BufferModel extends ChangeNotifier {
	bool? _away;

	UnmodifiableListView<MessageModel> get messages => UnmodifiableListView(_messages);
	Map<String, MessageModel> get messagesById => UnmodifiableMapView(_messagesById);

	BufferModel({ required this.entry, required this.network }) {
		assert(entry.id != null);
@@ -485,31 +487,71 @@ class BufferModel extends ChangeNotifier {
		notifyListeners();
	}


	void _appendMessages(Iterable<MessageModel> msgs) {
			// 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();
			_messages.addAll(msgs);

			_messagesById.addEntries(
					msgs
					.map((msg) => MapEntry(msg.entry.networkMsgid, msg))
					.where((entry) => entry.key != null)
					.map((entry) => MapEntry(entry.key!, entry.value)));
	}

	void _prependMessages(Iterable<MessageModel> msgs) {
			msgs = msgs.toList();
			_messages = [...msgs, ..._messages];
			_messagesById.addEntries(
					msgs
					.map((msg) => MapEntry(msg.entry.networkMsgid, msg))
					.where((entry) => entry.key != null)
					.map((entry) => MapEntry(entry.key!, entry.value)));
	}


	void addMessages(Iterable<MessageModel> msgs, { bool append = false }) {
		assert(messageHistoryLoaded);
		if (msgs.isEmpty) {
			return;
		}
		if (append) {
			_messages.addAll(msgs);
			_appendMessages(msgs);
		} else {
			// TODO: optimize this case
			_messages.addAll(msgs);
			_appendMessages(msgs);
			_messages.sort(_compareMessageModels);
		}
		notifyListeners();
	}

	void addReactions(Iterable<ReactionEntry> reacts) {
		assert(messageHistoryLoaded);
		if (reacts.isEmpty) {
			return;
		}

		for (var reaction in reacts) {
			_messagesById[reaction.inReplyToMsgid!]
				?.addReaction(reaction);
		}

		notifyListeners();
	}

	void populateMessageHistory(List<MessageModel> l) {
		// The messages passed here must be already sorted by the caller, and
		// must always come before the existing messages
		if (!_messageHistoryLoaded) {
			assert(_messages.isEmpty);
			_messages = l;
			_appendMessages(l);
			_messageHistoryLoaded = true;
		} else if (!l.isEmpty) {
			assert(l.last.entry.time.compareTo(_messages.first.entry.time) <= 0);
			_messages = [...l, ..._messages];
			_prependMessages(l);
		}
		notifyListeners();
	}
@@ -560,6 +602,20 @@ class MessageModel {
			// instead of taking a list and storing it.
			reactions = [...reactions],
			assert(entry.id != null);

	void addReaction(ReactionEntry newReact) {
		var oldReact = reactions.cast<ReactionEntry?>()
			.singleWhere((react) => react!.nick == newReact.nick, orElse: () => null);

		if (oldReact != null) {
			if(oldReact.dateTime.isAfter(newReact.dateTime)) {
				// our existing react was sent after the new one, throw the new one away.
				return;
			}
			// otherwise, we need to replace our old reaction. remove it.
			reactions.remove(oldReact);
		}
		reactions.add(newReact);
	}

	int get id => entry.id!;
diff --git a/lib/push.dart b/lib/push.dart
index 340f8cb..829c276 100644
--- a/lib/push.dart
+++ b/lib/push.dart
@@ -11,6 +11,7 @@ import 'models.dart';
import 'notification_controller.dart';
import 'prefs.dart';
import 'webpush.dart';
import 'client.dart';

class PushSubscription {
	final String endpoint;
@@ -66,21 +67,22 @@ Future<void> handlePushMessage(DB db, WebPushSubscriptionEntry sub, List<int> ci

	switch (msg.cmd) {
	case 'PRIVMSG':
		var ctcp = CtcpMessage.parse(msg);
		var privmsg = Privmsg.from(msg);
		var ctcp = CtcpMessage.parse(privmsg);
		if (ctcp != null && ctcp.cmd != 'ACTION') {
			log.print('Ignoring CTCP ${ctcp.cmd} message');
			break;
		}

		var target = msg.params[0];
		var target = privmsg.target;
		var isChannel = _isChannel(target, networkEntry.isupport);
		if (!isChannel) {
			var channelCtx = msg.tags['+draft/channel-context'];
			var channelCtx = privmsg.tags['+draft/channel-context'];
			if (channelCtx != null && _isChannel(channelCtx, networkEntry.isupport) && await _fetchBuffer(db, channelCtx, networkEntry) != null) {
				target = channelCtx;
				isChannel = true;
			} else {
				target = msg.source!.name;
				target = privmsg.source!.name;
			}
		}

@@ -95,7 +97,7 @@ Future<void> handlePushMessage(DB db, WebPushSubscriptionEntry sub, List<int> ci
			break;
		}

		var msgEntry = MessageEntry(msg, bufferEntry.id!);
		var msgEntry = MessageEntry(privmsg, bufferEntry.id!);

		if (isChannel) {
			await notifController.showHighlight([msgEntry], buffer);
diff --git a/lib/widget/composer.dart b/lib/widget/composer.dart
index cf956f9..d6a8846 100644
--- a/lib/widget/composer.dart
+++ b/lib/widget/composer.dart
@@ -48,11 +48,11 @@ class ComposerState extends State<Composer> {
		return client.isupport.lineLen - raw.length;
	}

	List<IrcMessage> _buildPrivmsg(String text) {
	List<Privmsg> _buildPrivmsg(String text) {
		var buffer = context.read<BufferModel>();
		var maxLen = _getMaxPrivmsgLen();

		List<IrcMessage> messages = [];
		List<Privmsg> messages = [];
		for (var line in text.split('\n')) {
			Map<String, String?> tags = {};
			if (messages.isEmpty && _replyTo?.msg.tags['msgid'] != null) {
@@ -70,26 +70,26 @@ class ComposerState extends State<Composer> {
				var leading = line.substring(0, i + 1);
				line = line.substring(i + 1);

				messages.add(IrcMessage('PRIVMSG', [buffer.name, leading], tags: tags));
				messages.add(Privmsg(buffer.name, leading, tags: tags));
			}

			// We'll get ERR_NOTEXTTOSEND if we try to send an empty message
			if (line != '') {
				messages.add(IrcMessage('PRIVMSG', [buffer.name, line], tags: tags));
				messages.add(Privmsg(buffer.name, line, tags: tags));
			}
		}

		return messages;
	}

	void _send(List<IrcMessage> messages) async {
	void _send(List<PrivmsgLike> messages) async {
		var buffer = context.read<BufferModel>();
		var client = context.read<Client>();
		var db = context.read<DB>();
		var bufferList = context.read<BufferListModel>();
		var network = context.read<NetworkModel>();

		List<Future<IrcMessage>> futures = [];
		List<Future<PrivmsgLike>> futures = [];
		for (var msg in messages) {
			futures.add(client.sendTextMessage(msg));
		}
@@ -145,7 +145,7 @@ class ComposerState extends State<Composer> {
		}
		if (msgText != null) {
			var buffer = context.read<BufferModel>();
			var msg = IrcMessage('PRIVMSG', [buffer.name, msgText]);
			var msg = Privmsg(buffer.name, msgText);
			_send([msg]);
		}
	}
-- 
2.42.1

[PATCH v2 3/5] refactor view Export this patch

This commit refactors the buffer view so that it uses semantic widgets
for its message items, instead of bundling them into one helper class.
The hierarchy is as follows: Each element in the buffer list is a
`_BufferItem`, which contains decorations (such as unread counts) and a
single `MessageItemContainer`. A `MessageItemContainer` contains at
one `ConcreteMessageItem` which renders a single message, and perhaps a
link preview. There are several posibilities for `ConcreteMessageItem`.

This commit retains as much code sharing as possible, while retaining a
separation of concerns for different kinds of Message views.

This refactor fixes several bugs, including:
* Unread counter and date flashes with the following message
* CTCP messages have different link preview behaviour in compact and
  non-compact mode.
---
 lib/models.dart               |   3 +-
 lib/page/buffer.dart          | 420 ++++----------------------
 lib/widget/message_item.dart  | 547 ++++++++++++++++++++++++++++++++++
 lib/widget/message_sheet.dart |   2 +-
 4 files changed, 600 insertions(+), 372 deletions(-)
 create mode 100644 lib/widget/message_item.dart

diff --git a/lib/models.dart b/lib/models.dart
index 11d94fc..473411d 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';

import 'database.dart';
import 'irc.dart';
import 'client.dart';

// This file contains models. Models are data structures which are can be
// listened to by UI elements so that the UI is updated whenever they change.
@@ -619,7 +620,7 @@ class MessageModel {
	}

	int get id => entry.id!;
	IrcMessage get msg => entry.msg;
	PrivmsgLike get msg => entry.msg;
}

class MemberListModel extends ChangeNotifier {
diff --git a/lib/page/buffer.dart b/lib/page/buffer.dart
index 4a13a68..59839d9 100644
--- a/lib/page/buffer.dart
+++ b/lib/page/buffer.dart
@@ -9,16 +9,13 @@ import '../client.dart';
import '../client_controller.dart';
import '../database.dart';
import '../irc.dart';
import '../linkify.dart';
import '../logging.dart';
import '../models.dart';
import '../notification_controller.dart';
import '../prefs.dart';
import '../widget/composer.dart';
import '../widget/link_preview.dart';
import '../widget/message_sheet.dart';
import '../widget/message_item.dart';
import '../widget/network_indicator.dart';
import '../widget/swipe_action.dart';
import 'buffer_details.dart';
import 'buffer_list.dart';

@@ -425,15 +422,6 @@ class _BufferPageState extends State<BufferPage> with WidgetsBindingObserver, Si
					var prevMsg = msgIndex > 0 ? messages[msgIndex - 1] : null;
					var key = ValueKey(msg.id);

					if (compact) {
						return _CompactMessageItem(
							key: key,
							msg: msg,
							prevMsg: prevMsg,
							last: msgIndex == messages.length - 1,
						);
					}

					var nextMsg = msgIndex + 1 < messages.length ? messages[msgIndex + 1] : null;

					VoidCallback? onSwipe;
@@ -442,20 +430,18 @@ class _BufferPageState extends State<BufferPage> with WidgetsBindingObserver, Si
							_composerKey.currentState!.replyTo(msg);
						};
					}

					Widget msgWidget = _MessageItem(
					return _BufferItem(
						key: key,
						msg: msg,
						prevMsg: prevMsg,
						nextMsg: nextMsg,
						compact: compact,
						unreadMarkerTime: widget.unreadMarkerTime,
						onSwipe: onSwipe,
						onMsgRefTap: _handleMsgRefTap,
						blinking: (index == _blinkMsgIndex),
						opacity: _blinkMsgController,
					);
					if (index == _blinkMsgIndex) {
						msgWidget = FadeTransition(opacity: _blinkMsgController, child: msgWidget);
					}
					return msgWidget;
				},
			);
		} else {
@@ -573,155 +559,39 @@ class _BufferPageState extends State<BufferPage> with WidgetsBindingObserver, Si
	}
}

class _CompactMessageItem extends StatelessWidget {
	final MessageModel msg;
	final MessageModel? prevMsg;
	final bool last;

	const _CompactMessageItem({
		super.key,
		required this.msg,
		this.prevMsg,
		this.last = false,
	});

	@override
	Widget build(BuildContext context) {
		var prefs = context.read<Prefs>();
		var ircMsg = msg.msg;
		var entry = msg.entry;
		var sender = ircMsg.source!.name;
		var localDateTime = entry.dateTime.toLocal();
		var ctcp = CtcpMessage.parse(ircMsg);
		assert(ircMsg.cmd == 'PRIVMSG' || ircMsg.cmd == 'NOTICE');

		var prevIrcMsg = prevMsg?.msg;
		var prevMsgSameSender = prevIrcMsg != null && ircMsg.source!.name == prevIrcMsg.source!.name;

		var textStyle = TextStyle(color: Theme.of(context).textTheme.bodyLarge!.color);

		String? text;
		List<TextSpan> textSpans;
		if (ctcp != null) {
			textStyle = textStyle.apply(fontStyle: FontStyle.italic);

			if (ctcp.cmd == 'ACTION') {
				text = ctcp.param;
				textSpans = applyAnsiFormatting(text ?? '', textStyle);
			} else {
				textSpans = [TextSpan(text: 'has sent a CTCP "${ctcp.cmd}" command', style: textStyle)];
			}
		} else {
			text = ircMsg.params[1];
			textSpans = applyAnsiFormatting(text, textStyle);
		}

		textSpans = textSpans.map((span) {
			var linkSpan = linkify(context, span.text!, linkStyle: TextStyle(decoration: TextDecoration.underline));
			return TextSpan(style: span.style, children: [linkSpan]);
		}).toList();

		List<Widget> stack = [];
		List<TextSpan> content = [];

		if (!prevMsgSameSender) {
			var colorSwatch = Colors.primaries[sender.hashCode % Colors.primaries.length];
			var colorScheme = ColorScheme.fromSwatch(primarySwatch: colorSwatch);
			var senderStyle = TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold);
			stack.add(Positioned(
				top: 0,
				left: 0,
				child: Text(sender, style: senderStyle),
			));
			content.add(TextSpan(
				text: sender,
				style: senderStyle.apply(color: Color(0x00000000)),
			));
		}

		content.addAll(textSpans);

		var prevEntry = prevMsg?.entry;
		if (!prevMsgSameSender || prevEntry == null || entry.dateTime.difference(prevEntry.dateTime) > Duration(minutes: 2)) {
			var hh = localDateTime.hour.toString().padLeft(2, '0');
			var mm = localDateTime.minute.toString().padLeft(2, '0');
			var timeText = '\u00A0[$hh:$mm]';
			var timeStyle = TextStyle(color: Theme.of(context).textTheme.bodySmall!.color);
			stack.add(Positioned(
				bottom: 0,
				right: 0,
				child: Text(timeText, style: timeStyle),
			));
			content.add(TextSpan(
				text: timeText,
				style: timeStyle.apply(color: Color(0x00000000)),
			));
		}

		stack.add(Container(
			margin: EdgeInsets.only(left: 4),
			child: SelectableText.rich(
				TextSpan(
					children: content,
				),
			),
		));

		Widget? linkPreview;
		if (prefs.linkPreview && text != null) {
			var body = stripAnsiFormatting(text);
			linkPreview = LinkPreview(
				text: body,
				builder: (context, child) {
					return Align(alignment: Alignment.center, child: Container(
						margin: EdgeInsets.symmetric(vertical: 5),
						child: ClipRRect(
							borderRadius: BorderRadius.circular(10),
							child: child,
						),
					));
				},
			);
		}

		return Container(
			margin: EdgeInsets.only(top: prevMsgSameSender ? 0 : 2.5, bottom: last ? 10 : 0, left: 4, right: 5),
			child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
				Stack(children: stack),
				if (linkPreview != null) linkPreview,
			]),
		);
	}
}

class _MessageItem extends StatelessWidget {
/// An entry in the message buffer.
///
/// This may consist of a single [MessageItemContainer], or it may include
/// information such as unread count and time.
class _BufferItem extends StatelessWidget {
	final MessageModel msg;
	final MessageModel? prevMsg, nextMsg;
	final String? unreadMarkerTime;
	final VoidCallback? onSwipe;
	final void Function(int)? onMsgRefTap;
	final bool compact;
	final bool blinking;
	final Animation<double> opacity;

	const _MessageItem({
	const _BufferItem({
		super.key,
		required this.msg,
		required this.compact,
		required this.blinking,
		this.prevMsg,
		this.nextMsg,
		this.unreadMarkerTime,
		this.onSwipe,
		this.onMsgRefTap,
		required this.opacity,
	});

	@override
	Widget build(BuildContext context) {
		var client = context.read<Client>();
		var prefs = context.read<Prefs>();

		var ircMsg = msg.msg;
		var entry = msg.entry;
		var sender = ircMsg.source!.name;
		var localDateTime = entry.dateTime.toLocal();
		var ctcp = CtcpMessage.parse(ircMsg);
		assert(ircMsg.cmd == 'PRIVMSG' || ircMsg.cmd == 'NOTICE');

		var prevIrcMsg = prevMsg?.msg;
		var prevCtcp = prevIrcMsg != null ? CtcpMessage.parse(prevIrcMsg) : null;
@@ -734,236 +604,46 @@ class _MessageItem extends StatelessWidget {
		var isAction = ctcp != null && ctcp.cmd == 'ACTION';
		var showUnreadMarker = prevEntry != null && unreadMarkerTime != null && unreadMarkerTime!.compareTo(entry.time) < 0 && unreadMarkerTime!.compareTo(prevEntry.time) >= 0;
		var showDateMarker = prevEntry == null || !_isSameDate(localDateTime, prevEntry.dateTime.toLocal());
		var isFirstInGroup = showUnreadMarker || !prevMsgSameSender || (prevMsgIsAction != isAction);
		var showTime = !nextMsgSameSender || nextMsg!.entry.dateTime.difference(entry.dateTime) > Duration(minutes: 2);

		var unreadMarkerColor = Theme.of(context).colorScheme.secondary;
		var eventColor = DefaultTextStyle.of(context).style.color!.withOpacity(0.5);

		var boxColor = Colors.primaries[sender.hashCode % Colors.primaries.length].shade500;
		var boxAlignment = Alignment.centerLeft;
		var textColor = DefaultTextStyle.of(context).style.color!;

		if (client.isMyNick(sender)) {
			// Actions are displayed as if they were told by an external
			// narrator. To preserve this effect, always show actions on the
			// left side.
			boxColor = Colors.grey.shade200;
			if (!isAction) {
				boxAlignment = Alignment.centerRight;
			}
		}

		if (!isAction) {
			textColor = boxColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
		}

		const margin = 16.0;
		var marginBottom = margin;
		if (nextMsg != null) {
			marginBottom = 0.0;
		}
		var marginTop = margin;
		if (!isFirstInGroup) {
			marginTop = margin / 4;
		}

		var senderTextSpan = TextSpan(
			text: sender,
			style: TextStyle(fontWeight: FontWeight.bold),
		Widget msgWidget = MessageItemContainer(
			key: key,
			msg: msg,
			unreadMarkerTime: unreadMarkerTime,
			onSwipe: onSwipe,
			onMsgRefTap: onMsgRefTap,
			compact: compact,
			isFirstInGroup: showUnreadMarker || !prevMsgSameSender || (prevMsgIsAction != isAction),
			last: (nextMsg == null),
			showTime: !nextMsgSameSender || nextMsg!.entry.dateTime.difference(entry.dateTime) > Duration(minutes: 2),
		);
		if (ircMsg.tags['+draft/channel-context'] != null) {
			senderTextSpan = TextSpan(children: [
				senderTextSpan,
				TextSpan(text: ' (only visible to you)', style: TextStyle(color: textColor.withOpacity(0.5))),
			]);
		}

		var linkStyle = TextStyle(decoration: TextDecoration.underline);

		List<InlineSpan> content;
		Widget? linkPreview;
		if (isAction) {
			var actionText = stripAnsiFormatting(ctcp.param ?? '');

			content = [
				WidgetSpan(
					child: Container(
						width: 8.0,
						height: 8.0,
						margin: EdgeInsets.all(3.0),
						decoration: BoxDecoration(
							shape: BoxShape.circle,
							color: boxColor,
						),
					),
				),
				senderTextSpan,
				TextSpan(text: ' '),
				linkify(context, actionText, linkStyle: linkStyle),
			];
		} else {
			var body = ircMsg.params[1];
			WidgetSpan? replyChip;
			if (msg.replyTo != null && msg.replyTo!.msg.source != null) {
				var replyNickname = msg.replyTo!.msg.source!.name;

				var replyPrefix = '$replyNickname: ';
				if (body.startsWith(replyPrefix)) {
					body = body.replaceFirst(replyPrefix, '');
				}

				replyChip = WidgetSpan(
					alignment: PlaceholderAlignment.middle,
					child: ActionChip(
						avatar: Icon(Icons.reply, size: 16, color: textColor),
						label: Text(replyNickname),
						labelPadding: EdgeInsets.only(right: 4),
						backgroundColor: Color.alphaBlend(textColor.withOpacity(0.15), boxColor),
						labelStyle: TextStyle(color: textColor),
						visualDensity: VisualDensity(vertical: -4),
						onPressed: () {
							if (onMsgRefTap != null) {
								onMsgRefTap!(msg.replyTo!.id!);
							}
						},
					),
				);
			}

			body = stripAnsiFormatting(body);
			content = [
				if (isFirstInGroup) senderTextSpan,
				if (isFirstInGroup) TextSpan(text: '\n'),
				if (replyChip != null) replyChip,
				if (replyChip != null) WidgetSpan(child: SizedBox(width: 5, height: 5)),
				linkify(context, body, linkStyle: linkStyle),
			];

			if (prefs.linkPreview) {
				linkPreview = LinkPreview(
					text: body,
					builder: (context, child) {
						return Align(alignment: boxAlignment, child: Container(
							margin: EdgeInsets.only(top: 5),
							child: ClipRRect(
								borderRadius: BorderRadius.circular(10),
								child: child,
							),
						));
					},
				);
			}
		}

		Widget inner = Text.rich(TextSpan(children: content));

		if (showTime) {
			var hh = localDateTime.hour.toString().padLeft(2, '0');
			var mm = localDateTime.minute.toString().padLeft(2, '0');
			var time = '   $hh:$mm';
			var timeScreenReader = 'Sent at $hh $mm';
			var timeStyle = DefaultTextStyle.of(context).style.apply(
				color: textColor.withOpacity(0.5),
				fontSizeFactor: 0.8,
			);

			// Add a fully transparent text span with the time, so that the real
			// time text doesn't collide with the message text.
			content.add(WidgetSpan(
				child: Text(
					time,
					style: timeStyle.apply(color: Color(0x00000000)),
					semanticsLabel: '',  // Make screen reader quiet
				),
			));

			inner = Stack(children: [
				inner,
				Positioned(
					bottom: 0,
					right: 0,
					child: Text(
						time,
						style: timeStyle,
						semanticsLabel: timeScreenReader,
					),
				),
			]);
		if (blinking) {
			msgWidget = FadeTransition(opacity: opacity, child: msgWidget);
		}

		inner = DefaultTextStyle.merge(style: TextStyle(color: textColor), child: inner);

		Widget decoratedMessage;
		if (isAction) {
			decoratedMessage = Align(
				alignment: boxAlignment,
				child: Container(
					child: inner,
				),
			);
		} else {
			decoratedMessage = Align(
				alignment: boxAlignment,
				child: Container(
					decoration: BoxDecoration(
						borderRadius: BorderRadius.circular(10),
						color: boxColor,
					),
					padding: EdgeInsets.all(10),
					child: inner,
				),
			);
		}
		const margin = 16.0;
		var eventColor = DefaultTextStyle.of(context).style.color!.withOpacity(0.5);

		if (!client.isMyNick(sender)) {
			decoratedMessage = SwipeAction(
				child: decoratedMessage,
				background: Align(
					alignment: Alignment.centerLeft,
					child: Opacity(
						opacity: 0.6,
						child: Icon(Icons.reply),
		var unreadMarkerColor = Theme.of(context).colorScheme.secondary;
		var informationWidgets = [
			if (showUnreadMarker) Container(
					margin: EdgeInsets.only(top: margin),
					child: Row(children: [
						Expanded(child: Divider(color: unreadMarkerColor)),
						SizedBox(width: 10),
						Text('Unread messages', style: TextStyle(color: unreadMarkerColor)),
						SizedBox(width: 10),
						Expanded(child: Divider(color: unreadMarkerColor)),
					]),
					),
				),
				onSwipe: onSwipe,
			);
		}

		// TODO: support actions as well
		if (!isAction) {
			decoratedMessage = GestureDetector(
				onLongPress: () {
					var buffer = context.read<BufferModel>();
					MessageSheet.open(context, buffer, msg, onSwipe);
				},
				child: decoratedMessage,
			);
				if (showDateMarker) Container(
						margin: EdgeInsets.symmetric(vertical: 20),
						child: Center(child: Text(_formatDate(localDateTime), style: TextStyle(color: eventColor))),
						),
		];
		if (informationWidgets.isNotEmpty) {
			msgWidget = Column(children: [...informationWidgets, msgWidget]);
		}

		return Column(children: [
			if (showUnreadMarker) Container(
				margin: EdgeInsets.only(top: margin),
				child: Row(children: [
					Expanded(child: Divider(color: unreadMarkerColor)),
					SizedBox(width: 10),
					Text('Unread messages', style: TextStyle(color: unreadMarkerColor)),
					SizedBox(width: 10),
					Expanded(child: Divider(color: unreadMarkerColor)),
				]),
			),
			if (showDateMarker) Container(
				margin: EdgeInsets.symmetric(vertical: 20),
				child: Center(child: Text(_formatDate(localDateTime), style: TextStyle(color: eventColor))),
			),
			Container(
				margin: EdgeInsets.only(left: margin, right: margin, top: marginTop, bottom: marginBottom),
				child: Column(children: [
					decoratedMessage,
					if (linkPreview != null) linkPreview,
				]),
			),
		]);
		return msgWidget;
	}
}

diff --git a/lib/widget/message_item.dart b/lib/widget/message_item.dart
new file mode 100644
index 0000000..575871f
--- /dev/null
+++ b/lib/widget/message_item.dart
@@ -0,0 +1,547 @@
import 'package:flutter/material.dart';
import 'package:goguma/models.dart';
import 'package:provider/provider.dart';

import '../ansi.dart';
import '../client.dart';
import '../database.dart';
import '../irc.dart';
import '../linkify.dart';
import '../prefs.dart';
import '../widget/link_preview.dart';
import '../widget/swipe_action.dart';

typedef MsgRefTapAction = void Function(int);

/// A view for a generic message item and link preview.
class MessageItemContainer extends StatelessWidget {
		final MessageModel msg;
		final bool compact;
		final String? unreadMarkerTime;
		final VoidCallback? onSwipe;
		final MsgRefTapAction? onMsgRefTap;
		final bool isFirstInGroup;
		final bool last;
		final bool showTime;

	const MessageItemContainer({
		super.key,
		required this.msg,
		required this.compact,
		required this.isFirstInGroup,
		required this.last,
		required this.showTime,
		this.unreadMarkerTime,
		this.onSwipe,
		this.onMsgRefTap,
	});

	@override
	Widget build(BuildContext context) {
		var prefs = context.read<Prefs>();
		var ircMsg = msg.msg;
		var ctcp = CtcpMessage.parse(ircMsg);
		var isAction = ctcp != null && ctcp.cmd == 'ACTION';

		// What if we have a non-action CTCP

		ConcreteMessageItem item;
		if (compact) {
			item = _CompactMessageItem(
				key: key,
				msg: msg,
				isFirstInGroup: isFirstInGroup,
				last: last,
				showTime: showTime,
			);
		} else if (ctcp != null && isAction) {
			return _CTCPActionMessageItem(
				key: key,
				msg: msg,
				ctcp: ctcp,
				onSwipe: onSwipe,
				isFirstInGroup: isFirstInGroup,
				last: last,
				showTime: showTime
			);
		} else {
			item = _DescriptiveMessageItem(
				key: key,
				msg: msg,
				onSwipe: onSwipe,
				onMsgRefTap: onMsgRefTap,
				isFirstInGroup: isFirstInGroup,
				last: last,
				showTime: showTime,
			);
		}

		Widget widget = item;
		if (prefs.linkPreview) {
			widget = _LinkPreviewContainer(
				key: key,
				child: item,
			);
		}
		return Container(
				margin: EdgeInsets.only(
					left: item.marginLeft,
					right: item.marginRight,
					top: item.marginTop,
					bottom: item.marginBottom,
				),
				child: widget
		);
	}
}

/// Base class for views which render a single IRC message.
abstract class ConcreteMessageItem extends StatelessWidget {
	final MessageModel msg;
	final bool last;
	final bool isFirstInGroup;

	PrivmsgLike get ircMsg => msg.msg;
	MessageEntry get entry => msg.entry;
	String get sender => ircMsg.source!.name;

	double get margin => 16.0;
	double get marginTop => isFirstInGroup ? margin : margin / 4;
	double get marginBottom => last ? margin : 0.0;
	double get marginLeft => margin;
	double get marginRight => margin;

	DateTime get localDateTime => entry.dateTime.toLocal();

	const ConcreteMessageItem({
		super.key,
		required this.msg,
		required this.last,
		required this.isFirstInGroup,
	});

	Alignment boxAlignment(BuildContext context) {
		if (context.read<Client>().isMyNick(sender)) {
			return Alignment.centerRight;
		} else {
			return Alignment.centerLeft;
		}
	}
}

/// A view for a CTCP Action, displayed in narrative form.
class _CTCPActionMessageItem extends ConcreteMessageItem {
	final CtcpMessage ctcp;
	final bool showTime;
	final VoidCallback? onSwipe;

	const _CTCPActionMessageItem ({
		super.key,
		required super.msg,
		required this.ctcp,
		required super.isFirstInGroup,
		required super.last,
		required this.showTime,
		this.onSwipe,
	});

	@override
	Alignment boxAlignment(BuildContext context) => Alignment.centerLeft;

	@override
	Widget build(BuildContext context) {
		var client = context.read<Client>();

		var boxColor = Colors.primaries[sender.hashCode % Colors.primaries.length].shade500;
		var textColor = DefaultTextStyle.of(context).style.color!;

		// Actions are displayed as if they were told by an external
		// narrator. To preserve this effect, always show actions on the
		// left side.
		if (client.isMyNick(sender)) {
			boxColor = Colors.grey.shade200;
		}

		var senderTextSpan = TextSpan(
			text: sender,
			style: TextStyle(fontWeight: FontWeight.bold),
		);
		if (ircMsg.tags['+draft/channel-context'] != null) {
			senderTextSpan = TextSpan(children: [
				senderTextSpan,
				TextSpan(text: ' (only visible to you)', style: TextStyle(color: textColor.withOpacity(0.5))),
			]);
		}

		var linkStyle = TextStyle(decoration: TextDecoration.underline);

		List<InlineSpan> content;
		var actionText = stripAnsiFormatting(ctcp.param ?? '');

		content = [
			WidgetSpan(
				child: Container(
					width: 8.0,
					height: 8.0,
					margin: EdgeInsets.all(3.0),
					decoration: BoxDecoration(
						shape: BoxShape.circle,
						color: boxColor,
					),
				),
			),
			senderTextSpan,
			TextSpan(text: ' '),
			linkify(context, actionText, linkStyle: linkStyle),
		];

		Widget decoratedMessage = Align(
			alignment: boxAlignment(context),
			child: _DescriptiveTimeContainer(
					showTime: showTime,
					context: context,
					content: content,
					localDateTime: localDateTime,
					textColor: textColor
			),
		);

		if (!client.isMyNick(sender)) {
			decoratedMessage = SwipeAction(
				child: decoratedMessage,
				background: Align(
					alignment: Alignment.centerLeft,
					child: Opacity(
						opacity: 0.6,
						child: Icon(Icons.reply),
					),
				),
				onSwipe: onSwipe,
			);
		}

		// TODO: support long-pressing actions
		return decoratedMessage;
	}
}


/// A view which contains a message followed by a link preview.
class _LinkPreviewContainer extends StatelessWidget {
	final ConcreteMessageItem child;
	final String content;

	_LinkPreviewContainer({
		super.key,
		required this.child,
	}) : content = child.ircMsg.messageContent;

	@override
	Widget build(BuildContext context) {
		var messageItem = child;
		var linkPreview = LinkPreview(
			text: stripAnsiFormatting(content),
			builder: (context, child) {
			return Align(
					alignment: messageItem.boxAlignment(context),
					child: Container(
						margin: EdgeInsets.only(top: 5),
						child: ClipRRect(
							borderRadius: BorderRadius.circular(10),
							child: child,
							),
						),
				);
			},
		);
		return Column(
				children: [
					messageItem,
					linkPreview,
				],
			);
		}

}

/// A single descriptive message bubble.
class _DescriptiveMessageItem extends ConcreteMessageItem {
	final bool showTime;
	final VoidCallback? onSwipe;
	final MsgRefTapAction? onMsgRefTap;

	const _DescriptiveMessageItem({
		super.key,
		required super.msg,
		required super.isFirstInGroup,
		required super.last,
		required this.showTime,
		this.onSwipe,
		this.onMsgRefTap,
	});

	@override
	Widget build(BuildContext context) {
		var client = context.read<Client>();

		var boxColor = Colors.primaries[sender.hashCode % Colors.primaries.length].shade500;

		if (client.isMyNick(sender)) {
			boxColor = Colors.grey.shade200;
		}

		var textColor = boxColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;

		var senderTextSpan = TextSpan(
			text: sender,
			style: TextStyle(fontWeight: FontWeight.bold),
		);
		if (ircMsg.tags['+draft/channel-context'] != null) {
			senderTextSpan = TextSpan(children: [
				senderTextSpan,
				TextSpan(text: ' (only visible to you)', style: TextStyle(color: textColor.withOpacity(0.5))),
			]);
		}

		var linkStyle = TextStyle(decoration: TextDecoration.underline);

		List<InlineSpan> content;
		var body = ircMsg.messageContent;
		WidgetSpan? replyChip;
		if (msg.replyTo != null && msg.replyTo!.msg.source != null) {
			var replyNickname = msg.replyTo!.msg.source!.name;

			var replyPrefix = '$replyNickname: ';
			if (body.startsWith(replyPrefix)) {
				body = body.replaceFirst(replyPrefix, '');
			}

			replyChip = WidgetSpan(
				alignment: PlaceholderAlignment.middle,
				child: ActionChip(
					avatar: Icon(Icons.reply, size: 16, color: textColor),
					label: Text(replyNickname),
					labelPadding: EdgeInsets.only(right: 4),
					backgroundColor: Color.alphaBlend(textColor.withOpacity(0.15), boxColor),
					labelStyle: TextStyle(color: textColor),
					visualDensity: VisualDensity(vertical: -4),
					onPressed: () {
						if (onMsgRefTap != null) {
							onMsgRefTap!(msg.replyTo!.id!);
						}
					},
				),
			);
		}

		body = stripAnsiFormatting(body);
		content = [
			if (isFirstInGroup) senderTextSpan,
			if (isFirstInGroup) TextSpan(text: '\n'),
			if (replyChip != null) replyChip,
			if (replyChip != null) WidgetSpan(child: SizedBox(width: 5, height: 5)),
			linkify(context, body, linkStyle: linkStyle),
		];


		Widget decoratedMessage = Align(
			alignment: boxAlignment(context),
			child: Container(
				decoration: BoxDecoration(
					borderRadius: BorderRadius.circular(10),
					color: boxColor,
				),
				padding: EdgeInsets.all(10),
				child: _DescriptiveTimeContainer(
					showTime: showTime,
					context: context,
					content: content,
					localDateTime: localDateTime,
					textColor: textColor
				),
			),
		);

		if (!client.isMyNick(sender)) {
			decoratedMessage = SwipeAction(
				child: decoratedMessage,
				background: Align(
					alignment: Alignment.centerLeft,
					child: Opacity(
						opacity: 0.6,
						child: Icon(Icons.reply),
					),
				),
				onSwipe: onSwipe,
			);
		}

		return decoratedMessage;
	}
}

/// A container which adds a timestamp to a message.
class _DescriptiveTimeContainer extends StatelessWidget {
  const _DescriptiveTimeContainer({
    required this.showTime,
    required this.context,
    required this.content,
    required this.localDateTime,
    required this.textColor,
  });

  final bool showTime;
  final BuildContext context;
  final List<InlineSpan> content;
  final DateTime localDateTime;
  final Color textColor;

	@override
	Widget build(BuildContext context) {
		Widget inner = Text.rich(TextSpan(children: content));

		if (showTime) {
			var hh = localDateTime.hour.toString().padLeft(2, '0');
			var mm = localDateTime.minute.toString().padLeft(2, '0');
			var time = '   $hh:$mm';
			var timeScreenReader = 'Sent at $hh $mm';
			var timeStyle = DefaultTextStyle.of(context).style.apply(
					color: textColor.withOpacity(0.5),
					fontSizeFactor: 0.8,
					);

			// Add a fully transparent text span with the time, so that the real
			// time text doesn't collide with the message text.
			content.add(WidgetSpan(
				child: Text(
					time,
					style: timeStyle.apply(color: Color(0x00000000)),
					semanticsLabel: '',  // Make screen reader quiet
				),
			));

			inner = Stack(children: [
				inner,
				Positioned(
					bottom: 0,
					right: 0,
					child: Text(
						time,
						style: timeStyle,
						semanticsLabel: timeScreenReader,
					),
				),
			]);
		}

		return DefaultTextStyle.merge(style: TextStyle(color: textColor), child: inner);
	}
}

/// A compact message line.
class _CompactMessageItem extends ConcreteMessageItem {
	final bool showTime;

	const _CompactMessageItem({
		super.key,
		required super.msg,
		required super.isFirstInGroup,
		required super.last,
		required this.showTime,
	});

	@override
	double get marginTop => isFirstInGroup ? 0 : 2.5;
	@override
	double get marginBottom => last ? 10 : 0;
	@override
	double get marginLeft => 4;
	@override
	double get marginRight => 5;

	@override
	Widget build(BuildContext context) {
		var ctcp = CtcpMessage.parse(ircMsg);

		var textStyle = TextStyle(color: Theme.of(context).textTheme.bodyLarge!.color);

		String? text;
		List<TextSpan> textSpans;
		if (ctcp != null) {
			textStyle = textStyle.apply(fontStyle: FontStyle.italic);

			if (ctcp.cmd == 'ACTION') {
				text = ctcp.param;
				textSpans = applyAnsiFormatting(text ?? '', textStyle);
			} else {
				textSpans = [TextSpan(text: 'has sent a CTCP "${ctcp.cmd}" command', style: textStyle)];
			}
		} else {
			text = ircMsg.messageContent;
			textSpans = applyAnsiFormatting(text, textStyle);
		}

		textSpans = textSpans.map((span) {
			var linkSpan = linkify(context, span.text!, linkStyle: TextStyle(decoration: TextDecoration.underline));
			return TextSpan(style: span.style, children: [linkSpan]);
		}).toList();

		List<Widget> stack = [];
		List<TextSpan> content = [];

		if (isFirstInGroup) {
			var colorSwatch = Colors.primaries[sender.hashCode % Colors.primaries.length];
			var colorScheme = ColorScheme.fromSwatch(primarySwatch: colorSwatch);
			var senderStyle = TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold);
			stack.add(Positioned(
				top: 0,
				left: 0,
				child: Text(sender, style: senderStyle),
			));
			content.add(TextSpan(
				text: sender,
				style: senderStyle.apply(color: Color(0x00000000)),
			));
		}

		content.addAll(textSpans);

		if (showTime) {
			var hh = localDateTime.hour.toString().padLeft(2, '0');
			var mm = localDateTime.minute.toString().padLeft(2, '0');
			var timeText = '\u00A0[$hh:$mm]';
			var timeStyle = TextStyle(color: Theme.of(context).textTheme.bodySmall!.color);
			var timeScreenReader = 'Sent at $hh $mm';
			stack.add(Positioned(
				bottom: 0,
				right: 0,
				child: Text(
					timeText,
					style: timeStyle,
					semanticsLabel: timeScreenReader,
				),
			));
			content.add(TextSpan(
				text: timeText,
				style: timeStyle.apply(color: Color(0x00000000)),
				semanticsLabel: '',  // Make screen reader quiet
			));
		}

		stack.add(Container(
			margin: EdgeInsets.only(left: 4),
			child: SelectableText.rich(
				TextSpan(
					children: content,
				),
			),
		));

		return SizedBox(
			child: Stack(children: stack),
			// We want the compact message to take up the whole width of the screen.
			// If the minimum width is infinity, then it will stretch.
			width: double.infinity,
		);
	}
}
diff --git a/lib/widget/message_sheet.dart b/lib/widget/message_sheet.dart
index 0858ba8..87fc496 100644
--- a/lib/widget/message_sheet.dart
+++ b/lib/widget/message_sheet.dart
@@ -59,7 +59,7 @@ class MessageSheet extends StatelessWidget {
				title: Text('Copy'),
				leading: Icon(Icons.content_copy),
				onTap: () async {
					var body = stripAnsiFormatting(message.msg.params[1]);
					var body = stripAnsiFormatting(message.msg.messageContent);
					var text = '<$sender> $body';
					await Clipboard.setData(ClipboardData(text: text));
					if (context.mounted) {
-- 
2.42.1

[PATCH v2 4/5] view: add reactions Export this patch

This commit adds a minimal reaction UI to the view. Each reaction is
rendered as a chip underneath the message with unread count.
---
 lib/models.dart              |  9 +++++++
 lib/widget/message_item.dart | 52 +++++++++++++++++++++++++++++++++++-
 2 files changed, 60 insertions(+), 1 deletion(-)

diff --git a/lib/models.dart b/lib/models.dart
index 473411d..8b14da8 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -598,6 +598,15 @@ class MessageModel {
	final MessageEntry? replyTo;
	final List<ReactionEntry> reactions;

	Map<String, int> get reactionCounts => reactions.fold(
		{},
		(M, r) => M..update(
			r.text,
			(i) => i+1,
			ifAbsent: () => 1,
		),
	);

	MessageModel({ required this.entry, this.replyTo, required Iterable<ReactionEntry> reactions}) :
			// Our reaction list needs to be mutable. This is why we spread
			// instead of taking a list and storing it.
diff --git a/lib/widget/message_item.dart b/lib/widget/message_item.dart
index 575871f..8fad2d9 100644
--- a/lib/widget/message_item.dart
+++ b/lib/widget/message_item.dart
@@ -308,6 +308,9 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
		List<InlineSpan> content;
		var body = ircMsg.messageContent;
		WidgetSpan? replyChip;


		var alphaBlend = Color.alphaBlend(textColor.withOpacity(0.15), boxColor);
		if (msg.replyTo != null && msg.replyTo!.msg.source != null) {
			var replyNickname = msg.replyTo!.msg.source!.name;

@@ -322,7 +325,7 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
					avatar: Icon(Icons.reply, size: 16, color: textColor),
					label: Text(replyNickname),
					labelPadding: EdgeInsets.only(right: 4),
					backgroundColor: Color.alphaBlend(textColor.withOpacity(0.15), boxColor),
					backgroundColor: alphaBlend,
					labelStyle: TextStyle(color: textColor),
					visualDensity: VisualDensity(vertical: -4),
					onPressed: () {
@@ -362,6 +365,53 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
			),
		);

		if (msg.reactions.isNotEmpty) {
			WrapAlignment alignment;
			if (context.read<Client>().isMyNick(sender)) {
				alignment = WrapAlignment.end;
			} else {
				alignment = WrapAlignment.start;
			}
			Widget reactions = Wrap(
				spacing: margin / 4,
				runSpacing: margin / 4,
				children: msg.reactionCounts.entries.map((mapEntry) {
					var reaction = mapEntry.key;
					var count = mapEntry.value;
					if (count == 1) {
						return Chip(
							label: Text(reaction),
							backgroundColor: alphaBlend,
							labelStyle: TextStyle(color: textColor),
						);
					} else {
						return Chip(
							avatar: Text(
								reaction,
								style: TextStyle(color: textColor),
							),
							label: Text(count.toString()),
							backgroundColor: alphaBlend,
							labelStyle: TextStyle(color: textColor),
						);
					}
				}).toList(),
				alignment: alignment,
			);
			// We want our reactions to take up half the screen at most.
			// If they end up wrapping past two lines, things might get annoying.
			// We don't consider that case for now, because adding a 'show more' button would be very complicated.
			reactions = SizedBox(
				width: double.infinity,
				child: FractionallySizedBox(
					widthFactor: 0.5,
					alignment: boxAlignment(context),
					child: reactions,
				),
			);
			decoratedMessage = Column(children:[decoratedMessage, reactions]);
		}

		if (!client.isMyNick(sender)) {
			decoratedMessage = SwipeAction(
				child: decoratedMessage,
-- 
2.42.1

[PATCH v2 5/5] send reactions Export this patch

This commit allows the user to select one of several existing reactions
to reply to a message. It does not allow for selecting reactions from an
emoji list, or similar.
---
 lib/client.dart              | 220 ++++++++++++++++++++-------------
 lib/client_controller.dart   |  14 +--
 lib/database.dart            |   6 +
 lib/models.dart              |   7 +-
 lib/widget/composer.dart     |  37 +++---
 lib/widget/message_item.dart | 228 +++++++++++++++++++++++++----------
 6 files changed, 336 insertions(+), 176 deletions(-)

diff --git a/lib/client.dart b/lib/client.dart
index 212d54e..1b670c2 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -761,77 +761,75 @@ class Client {
		}
	}

	Future<PrivmsgLike> sendTextMessage(PrivmsgLike req) async {
		if (caps.enabled.contains('echo-message')) {
			// Assume the first echo-message we get is the one we're waiting
			// for. Rely on labeled-response to improve this assumption's
			// robustness. If labeled-response is not available, keep track of
			// the number of messages we've sent for that target.

			var cm = isupport.caseMapping;
			var target = req.params[0];

			String? pendingKey;
			var skip = 0;
			if (!caps.enabled.contains('labeled-response')) {
				pendingKey = req.cmd + ' ' + cm(target);
				skip = _pendingTextMsgs[pendingKey] ?? 0;
				_pendingTextMsgs[pendingKey] = skip + 1;
			}

			PrivmsgLike? echo;
			try {
				await _roundtripMessage(req, (reply) {
					bool match;
					switch (reply.cmd) {
					case ERR_NOSUCHNICK:
					case ERR_CANNOTSENDTOCHAN:
						match = cm(reply.params[1]) == cm(target);
						break;
					case ERR_NOTEXTTOSEND:
						match = true;
						break;
					default:
						match = reply.cmd == req.cmd && cm(reply.params[0]) == cm(target);
						break;
					}
					if (!match) {
						return false;
					}

					if (skip > 0) {
						skip--;
						return false;
					}

					if (reply.cmd != req.cmd) {
						throw IrcException(reply);
					} else {
						// We know `echo` is PrivmsgLike because the cmd matches
						echo = PrivmsgLike(reply);
						return true;
					}
				});
			} finally {
				if (pendingKey != null) {
					var n = _pendingTextMsgs[pendingKey]! - 1;
					if (n == 0) {
						_pendingTextMsgs.remove(pendingKey);
					} else {
						_pendingTextMsgs[pendingKey] = n;
					}
				}
			}

			return echo!;
		} else {
	Future<IrcMessage> sendTargetedMessage(TargetedMsg req) async {
		if (!caps.enabled.contains('echo-message')) {
			// Best-effort: assume a PING is enough.
			// TODO: catch errors
			send(req);
			await ping();
			return req.copyWith(source: IrcSource(nick));
		}
	}
		// Assume the first echo-message we get is the one we're waiting
		// for. Rely on labeled-response to improve this assumption's
		// robustness. If labeled-response is not available, keep track of
		// the number of messages we've sent for that target.

		var cm = isupport.caseMapping;
		var target = req.params[0];

		String? pendingKey;
		var skip = 0;
		if (!caps.enabled.contains('labeled-response')) {
			pendingKey = req.cmd + ' ' + cm(target);
			skip = _pendingTextMsgs[pendingKey] ?? 0;
			_pendingTextMsgs[pendingKey] = skip + 1;
		}

		IrcMessage? echo;
		try {
			await _roundtripMessage(req, (reply) {
				bool match;
				switch (reply.cmd) {
				case ERR_NOSUCHNICK:
				case ERR_CANNOTSENDTOCHAN:
					match = cm(reply.params[1]) == cm(target);
					break;
				case ERR_NOTEXTTOSEND:
					match = true;
					break;
				default:
					match = reply.cmd == req.cmd && cm(reply.params[0]) == cm(target);
					break;
				}
				if (!match) {
					return false;
				}

				if (skip > 0) {
					skip--;
					return false;
				}

				if (reply.cmd != req.cmd) {
					throw IrcException(reply);
				} else {
					echo = reply;
					return true;
				}
			});
		} finally {
			if (pendingKey != null) {
				var n = _pendingTextMsgs[pendingKey]! - 1;
				if (n == 0) {
					_pendingTextMsgs.remove(pendingKey);
				} else {
					_pendingTextMsgs[pendingKey] = n;
				}
			}
		}

		return echo!;
}

	bool supportsReadMarker() {
		return caps.enabled.contains('draft/read-marker');
@@ -1202,36 +1200,62 @@ class ClientMessage extends IrcMessage {
		return null;
	}
}
abstract class PrivmsgLike extends IrcMessage {

abstract class TargetedMsg extends IrcMessage {
	final String target;
	TargetedMsg(super.cmd, super.params, { super.tags, super.source }) :
		target = params[0],
		super();
}

class TagMsg extends TargetedMsg {
	TagMsg(String target, Map<String, String?> tags, {super.source}) :
			super('TAGMSG', [target] , tags: tags);

	TagMsg.reaction({required String target, required String inReplyTo, required String text}) :
			this(target, {'+draft/reply': inReplyTo, '+draft/react': text});

	@override
	TagMsg copyWith({
		IrcSource? source,
		Map<String, String?>? tags,
	}) {
		return TagMsg(
			target,
			tags ?? this.tags,
			source: source ?? this.source,
		);
	}
}

abstract class PrivmsgLike extends TargetedMsg {
	final String messageContent;

	PrivmsgLike._(IrcMessage msg) :
			assert(msg.params.length == 2),
			target = msg.params[0],
			messageContent = msg.params[1],
			super(msg.cmd, msg.params, tags: msg.tags, source: msg.source);

	factory PrivmsgLike(IrcMessage msg) {
		if (msg.cmd == 'PRIVMSG') {
	PrivmsgLike(super.cmd, super.params, { super.tags, super.source }) :
		assert(params.length == 2),
		messageContent = params[1],
		super();

	@override
	PrivmsgLike copyWith({ IrcSource? source, Map<String, String?>? tags });

	factory PrivmsgLike.from(IrcMessage msg) {
		switch (msg.cmd) {
		case 'PRIVMSG':
			return Privmsg.from(msg);
		} else if (msg.cmd == 'NOTICE') {
		case 'NOTICE':
			return Notice.from(msg);
		} else {
			throw ArgumentError.value(msg, 'msg', 'Message must be a PRIVMSG or NOTICE');
		}
	}

	@override
	PrivmsgLike copyWith({
		IrcSource? source,
		Map<String, String?>? tags,
	}) {
		return PrivmsgLike(super.copyWith(source: source, tags: tags));
		throw ArgumentError.value(msg, 'msg', 'Message must be a PRIVMSG or NOTICE');
	}

	static PrivmsgLike parse(String s) {
		return PrivmsgLike(IrcMessage.parse(s));
		return PrivmsgLike.from(IrcMessage.parse(s));
	}
}

@@ -1240,8 +1264,21 @@ class Privmsg extends PrivmsgLike {
			assert(msg.cmd == 'PRIVMSG'),
			super._();

	Privmsg(String target, String text, { Map<String, String?> tags = const {} }) :
		this.from(IrcMessage('PRIVMSG', [target, text], tags: tags));
	Privmsg(String target, String text, {super.tags, super.source}) :
		super('PRIVMSG', [target, text]);

	@override
	Privmsg copyWith({
		IrcSource? source,
		Map<String, String?>? tags,
	}) {
		return Privmsg(
			target,
			messageContent,
			source: source ?? this.source,
			tags: tags ?? this.tags,
		);
	}
}

class Notice extends PrivmsgLike {
@@ -1249,8 +1286,21 @@ class Notice extends PrivmsgLike {
			assert(msg.cmd == 'NOTICE'),
			super._();

	Notice(String target, String text) :
		this.from(IrcMessage('NOTICE', [target, text]));
	Notice(String target, String text, {super.tags, super.source}) :
		super('NOTICE', [target, text]);

	@override
	Notice copyWith({
		IrcSource? source,
		Map<String, String?>? tags,
	}) {
		return Notice(
			target,
			messageContent,
			source: source ?? this.source,
			tags: tags ?? this.tags,
		);
	}
}

class ClientEndOfNames extends ClientMessage {
diff --git a/lib/client_controller.dart b/lib/client_controller.dart
index 97ddc61..148fc3e 100644
--- a/lib/client_controller.dart
+++ b/lib/client_controller.dart
@@ -744,18 +744,10 @@ class ClientController {
		List<ReactionEntry> reactions = [];
		for (var msg in messages) {
			if (msg.cmd == 'NOTICE' || msg.cmd == 'PRIVMSG') {
				privmsgs.add(PrivmsgLike(msg));
				privmsgs.add(PrivmsgLike.from(msg));
			} else if (msg.cmd == 'TAGMSG') {
				var reply = msg.tags['+draft/reply'];
				var react = msg.tags['+draft/react'];
				if (reply != null && react != null) {
					reactions.add(ReactionEntry(
						nick: msg.source.name,
						buffer: buf.id,
						text: react,
						inReplyToMsgid: reply,
						time: msg.tags['time'] ?? formatIrcTime(DateTime.now()),
					));
				if (msg.tags.containsKey('+draft/reply') && msg.tags.containsKey('+draft/react')) {
					reactions.add(ReactionEntry.from(msg, buf.id));
				}
			}
		}
diff --git a/lib/database.dart b/lib/database.dart
index a30d3b9..f1acc00 100644
--- a/lib/database.dart
+++ b/lib/database.dart
@@ -251,6 +251,12 @@ class ReactionEntry {
		this.inReplyToMsgid,
	});

	ReactionEntry.from(IrcMessage msg, this.buffer) :
		time = msg.tags['time'] ?? formatIrcTime(DateTime.now()),
		inReplyToMsgid = msg.tags['+draft/reply']!,
		text = msg.tags['+draft/react']!,
		nick = msg.source!.name;

	Map<String, Object?> toMap() {
		return <String, Object?>{
			'id': id,
diff --git a/lib/models.dart b/lib/models.dart
index 8b14da8..5eea2a8 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -613,9 +613,12 @@ class MessageModel {
			reactions = [...reactions],
			assert(entry.id != null);

	ReactionEntry? reactionFrom(String nick) =>
		reactions.cast<ReactionEntry?>()
			.singleWhere((react) => react!.nick == nick, orElse: () => null);

	void addReaction(ReactionEntry newReact) {
		var oldReact = reactions.cast<ReactionEntry?>()
			.singleWhere((react) => react!.nick == newReact.nick, orElse: () => null);
		var oldReact = reactionFrom(newReact.nick);

		if (oldReact != null) {
			if(oldReact.dateTime.isAfter(newReact.dateTime)) {
diff --git a/lib/widget/composer.dart b/lib/widget/composer.dart
index d6a8846..314a99c 100644
--- a/lib/widget/composer.dart
+++ b/lib/widget/composer.dart
@@ -91,27 +91,30 @@ class ComposerState extends State<Composer> {

		List<Future<PrivmsgLike>> futures = [];
		for (var msg in messages) {
			futures.add(client.sendTextMessage(msg));
			// we know `echo` is PrivmsgLike because it echos a message we sent.
			futures.add(client.sendTargetedMessage(msg).then((echo) => PrivmsgLike.from(echo)));
		}

		if (!client.caps.enabled.contains('echo-message')) {
			messages = await Future.wait(futures);
		if (client.caps.enabled.contains('echo-message')) {
			return;
		}

			List<MessageEntry> entries = [];
			for (var msg in messages) {
				var entry = MessageEntry(msg, buffer.id);
				entries.add(entry);
			}
			await db.storeMessages(entries);
		messages = await Future.wait(futures);

			var models = await buildMessageModelList(db, entries);
			if (buffer.messageHistoryLoaded) {
				buffer.addMessages(models, append: true);
			}
			bufferList.bumpLastDeliveredTime(buffer, entries.last.time);
			if (network.networkEntry.bumpLastDeliveredTime(entries.last.time)) {
				await db.storeNetwork(network.networkEntry);
			}
		List<MessageEntry> entries = [];
		for (var msg in messages) {
			var entry = MessageEntry(msg, buffer.id);
			entries.add(entry);
		}
		await db.storeMessages(entries);

		var models = await buildMessageModelList(db, entries);
		if (buffer.messageHistoryLoaded) {
			buffer.addMessages(models, append: true);
		}
		bufferList.bumpLastDeliveredTime(buffer, entries.last.time);
		if (network.networkEntry.bumpLastDeliveredTime(entries.last.time)) {
			await db.storeNetwork(network.networkEntry);
		}
	}

diff --git a/lib/widget/message_item.dart b/lib/widget/message_item.dart
index 8fad2d9..41a6514 100644
--- a/lib/widget/message_item.dart
+++ b/lib/widget/message_item.dart
@@ -45,7 +45,7 @@ class MessageItemContainer extends StatelessWidget {

		// What if we have a non-action CTCP

		ConcreteMessageItem item;
		StatelessConcreteMessageItem item;
		if (compact) {
			item = _CompactMessageItem(
				key: key,
@@ -95,11 +95,11 @@ class MessageItemContainer extends StatelessWidget {
	}
}

/// Base class for views which render a single IRC message.
abstract class ConcreteMessageItem extends StatelessWidget {
	final MessageModel msg;
	final bool last;
	final bool isFirstInGroup;
/// Mixin class for views which render a single IRC message.
mixin ConcreteMessageItem on Widget {
	MessageModel get msg;
	bool get last;
	bool get isFirstInGroup;

	PrivmsgLike get ircMsg => msg.msg;
	MessageEntry get entry => msg.entry;
@@ -113,13 +113,6 @@ abstract class ConcreteMessageItem extends StatelessWidget {

	DateTime get localDateTime => entry.dateTime.toLocal();

	const ConcreteMessageItem({
		super.key,
		required this.msg,
		required this.last,
		required this.isFirstInGroup,
	});

	Alignment boxAlignment(BuildContext context) {
		if (context.read<Client>().isMyNick(sender)) {
			return Alignment.centerRight;
@@ -127,10 +120,34 @@ abstract class ConcreteMessageItem extends StatelessWidget {
			return Alignment.centerLeft;
		}
	}

	WrapAlignment wrapAlignment(BuildContext context) {
		if (context.read<Client>().isMyNick(sender)) {
			return WrapAlignment.end;
		} else {
			return WrapAlignment.start;
		}
	}
}

abstract class StatelessConcreteMessageItem extends StatelessWidget with ConcreteMessageItem {
	@override
	final MessageModel msg;
	@override
	final bool last;
	@override
	final bool isFirstInGroup;

	const StatelessConcreteMessageItem({
		super.key,
		required this.msg,
		required this.last,
		required this.isFirstInGroup,
	});
}

/// A view for a CTCP Action, displayed in narrative form.
class _CTCPActionMessageItem extends ConcreteMessageItem {
class _CTCPActionMessageItem extends StatelessConcreteMessageItem {
	final CtcpMessage ctcp;
	final bool showTime;
	final VoidCallback? onSwipe;
@@ -265,7 +282,7 @@ class _LinkPreviewContainer extends StatelessWidget {
}

/// A single descriptive message bubble.
class _DescriptiveMessageItem extends ConcreteMessageItem {
class _DescriptiveMessageItem extends StatelessConcreteMessageItem {
	final bool showTime;
	final VoidCallback? onSwipe;
	final MsgRefTapAction? onMsgRefTap;
@@ -310,7 +327,6 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
		WidgetSpan? replyChip;


		var alphaBlend = Color.alphaBlend(textColor.withOpacity(0.15), boxColor);
		if (msg.replyTo != null && msg.replyTo!.msg.source != null) {
			var replyNickname = msg.replyTo!.msg.source!.name;

@@ -325,7 +341,7 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
					avatar: Icon(Icons.reply, size: 16, color: textColor),
					label: Text(replyNickname),
					labelPadding: EdgeInsets.only(right: 4),
					backgroundColor: alphaBlend,
					backgroundColor: Color.alphaBlend(textColor.withOpacity(0.15), boxColor),
					labelStyle: TextStyle(color: textColor),
					visualDensity: VisualDensity(vertical: -4),
					onPressed: () {
@@ -366,50 +382,12 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
		);

		if (msg.reactions.isNotEmpty) {
			WrapAlignment alignment;
			if (context.read<Client>().isMyNick(sender)) {
				alignment = WrapAlignment.end;
			} else {
				alignment = WrapAlignment.start;
			}
			Widget reactions = Wrap(
				spacing: margin / 4,
				runSpacing: margin / 4,
				children: msg.reactionCounts.entries.map((mapEntry) {
					var reaction = mapEntry.key;
					var count = mapEntry.value;
					if (count == 1) {
						return Chip(
							label: Text(reaction),
							backgroundColor: alphaBlend,
							labelStyle: TextStyle(color: textColor),
						);
					} else {
						return Chip(
							avatar: Text(
								reaction,
								style: TextStyle(color: textColor),
							),
							label: Text(count.toString()),
							backgroundColor: alphaBlend,
							labelStyle: TextStyle(color: textColor),
						);
					}
				}).toList(),
				alignment: alignment,
			);
			// We want our reactions to take up half the screen at most.
			// If they end up wrapping past two lines, things might get annoying.
			// We don't consider that case for now, because adding a 'show more' button would be very complicated.
			reactions = SizedBox(
				width: double.infinity,
				child: FractionallySizedBox(
					widthFactor: 0.5,
					alignment: boxAlignment(context),
					child: reactions,
				),
			decoratedMessage = Column(
				children: [
					decoratedMessage,
					_ReactionsWidget(msg: msg)
				]
			);
			decoratedMessage = Column(children:[decoratedMessage, reactions]);
		}

		if (!client.isMyNick(sender)) {
@@ -430,6 +408,134 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
	}
}

class _ReactionsWidget extends StatefulWidget with ConcreteMessageItem {
	@override
	final MessageModel msg;
	@override
	bool get last => false;
	@override
	bool get isFirstInGroup => false;


	const _ReactionsWidget({
		super.key,
		required this.msg,
	});

	@override
	State<_ReactionsWidget> createState() => _ReactionsWidgetState();
}

class _ReactionsWidgetState extends State<_ReactionsWidget> {
	Client get client => context.read<Client>();
	ReactionEntry? get myReaction => widget.msg.reactionFrom(client.nick);

	Future<void> sendReaction(String reaction) async {
		var buffer = context.read<BufferModel>();
		var db = context.read<DB>();
		var bufferList = context.read<BufferListModel>();
		var network = context.read<NetworkModel>();

		var message = client.sendTargetedMessage(
			TagMsg.reaction(
				target: widget.ircMsg.target,
				inReplyTo: widget.entry.networkMsgid!,
				text: reaction,
			),
		);

		if (client.caps.enabled.contains('echo-message')) {
			return;
		}

		var entry = ReactionEntry.from(await message, buffer.id);

		await db.storeOrUpdateReactions([entry]);

		if (buffer.messageHistoryLoaded) {
			buffer.addReactions([entry]);
		}

		bufferList.bumpLastDeliveredTime(buffer, entry.time);

		if (network.networkEntry.bumpLastDeliveredTime(entry.time)) {
			await db.storeNetwork(network.networkEntry);
		}
	}

	@override
	Widget build(BuildContext context) {
		var theme = Theme.of(context);

		Widget reactions = Wrap(
			spacing: widget.margin / 4,
			runSpacing: widget.margin / 2,
			children: widget.msg.reactionCounts.entries.map((mapEntry) {
				var reaction = mapEntry.key;
				var count = mapEntry.value;
				var textStyle = theme.textTheme.labelMedium;
				Widget label = Text(
					reaction,
					style: textStyle,
				);
				Widget? avatar;
				if (count != 1) {
					avatar = label;
					label = Text(count.toString());
				}
				return ChoiceChip.elevated(
					avatar: avatar,
					label: label,
					labelStyle: textStyle,
					selected: reaction == myReaction?.text,
					shape: ReactionChipBorder(selectedColor: theme.colorScheme.outline),
					onSelected: (bool nowSelected) {
						if (!nowSelected) {
							// we currently don't support redaction
							return;
						}
						sendReaction(reaction);
					},
				);
			}).toList(),
			alignment: widget.wrapAlignment(context),
		);
		// We want our reactions to take up half the screen at most.
		// If they end up wrapping past two lines, things might get annoying.
		// We don't consider that case for now, because adding a 'show more' button would be very complicated.
		reactions = SizedBox(
			width: double.infinity,
			child: FractionallySizedBox(
				widthFactor: 0.5,
				alignment: widget.boxAlignment(context),
				child: reactions,
			),
		);
		return reactions;
	}
}

class ReactionChipBorder extends StadiumBorder
    implements MaterialStateOutlinedBorder {
	final Color selectedColor;

  const ReactionChipBorder({required this.selectedColor});

  @override
  OutlinedBorder? resolve(Set<MaterialState> states) {
    if (states.contains(MaterialState.selected)) {
      return StadiumBorder(
				side: BorderSide(
					width: .5,
					strokeAlign: BorderSide.strokeAlignCenter,
					color: selectedColor,
				),
			);
    } else {
      return const StadiumBorder();
		}
  }
}
/// A container which adds a timestamp to a message.
class _DescriptiveTimeContainer extends StatelessWidget {
  const _DescriptiveTimeContainer({
@@ -489,7 +595,7 @@ class _DescriptiveTimeContainer extends StatelessWidget {
}

/// A compact message line.
class _CompactMessageItem extends ConcreteMessageItem {
class _CompactMessageItem extends StatelessConcreteMessageItem {
	final bool showTime;

	const _CompactMessageItem({
-- 
2.42.1