~emersion/goguma-dev

Add emoji reactions v1 SUPERSEDED

Calvin Lee: 4
 model: add support for reactions
 irc: add support for reactions
 refactor view
 view: add reactions

 15 files changed, 991 insertions(+), 417 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/46539/mbox | git am -3
Learn more about email & git

[PATCH 1/4] 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..31f596b 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 INTEGER 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 INTEGER 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 2/4] 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 31f596b..8831ba8 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 3/4] 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 4/4] 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