~emersion/goguma-dev

Add emoji reactions v3 PROPOSED

Calvin Lee: 6
 model: add support for reactions
 irc: add support for reactions
 refactor view
 view: add reactions
 send reactions
 add emoji keyboard

 30 files changed, 1552 insertions(+), 678 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/46891/mbox | git am -3
Learn more about email & git

[PATCH v3 1/6] 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          | 128 +++++++++++++++++++++++++++++++++++++
 lib/models.dart            |   9 ++-
 pubspec.lock               |  26 ++++----
 4 files changed, 165 insertions(+), 26 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..edd45bd 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,
		required 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,65 @@ 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.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..0f956a7 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -553,10 +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!;
	IrcMessage get msg => entry.msg;
diff --git a/pubspec.lock b/pubspec.lock
index e4981e1..ed75e12 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -77,10 +77,10 @@ packages:
    dependency: transitive
    description:
      name: collection
      sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
      sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
      url: "https://pub.dev"
    source: hosted
    version: "1.17.2"
    version: "1.18.0"
  connectivity_plus:
    dependency: "direct main"
    description:
@@ -428,10 +428,10 @@ packages:
    dependency: transitive
    description:
      name: meta
      sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
      sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
      url: "https://pub.dev"
    source: hosted
    version: "1.9.1"
    version: "1.10.0"
  mime:
    dependency: transitive
    description:
@@ -713,18 +713,18 @@ packages:
    dependency: transitive
    description:
      name: stack_trace
      sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
      sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
      url: "https://pub.dev"
    source: hosted
    version: "1.11.0"
    version: "1.11.1"
  stream_channel:
    dependency: transitive
    description:
      name: stream_channel
      sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
      sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
      url: "https://pub.dev"
    source: hosted
    version: "2.1.1"
    version: "2.1.2"
  string_scanner:
    dependency: transitive
    description:
@@ -753,10 +753,10 @@ packages:
    dependency: transitive
    description:
      name: test_api
      sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8"
      sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
      url: "https://pub.dev"
    source: hosted
    version: "0.6.0"
    version: "0.6.1"
  timezone:
    dependency: transitive
    description:
@@ -881,10 +881,10 @@ packages:
    dependency: transitive
    description:
      name: web
      sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
      sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
      url: "https://pub.dev"
    source: hosted
    version: "0.1.4-beta"
    version: "0.3.0"
  webcrypto:
    dependency: "direct main"
    description:
@@ -934,5 +934,5 @@ packages:
    source: hosted
    version: "3.1.2"
sdks:
  dart: ">=3.1.2 <4.0.0"
  dart: ">=3.2.0-194.0.dev <4.0.0"
  flutter: ">=3.13.0"
-- 
2.42.1

[PATCH v3 2/6] 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.

We allow PRIVMSG-based reacts, and assume that they are a fallback for
clients which do not support reactions. Thus we do not show the PRIVMSG,
only the reaction.

Test: sent the following message
@+draft/reply=<reply-id>;+draft/react=💜 PRIVMSG #pounce-test :i love it!

and observed the reaction show up in #pounce-test
---
 lib/client.dart            | 84 +++++++++++++++++++++++++++++++++++---
 lib/client_controller.dart | 50 ++++++++++++++++++++---
 lib/database.dart          |  9 ++--
 lib/models.dart            | 65 +++++++++++++++++++++++++++--
 lib/push.dart              | 12 +++---
 lib/widget/composer.dart   | 14 +++----
 6 files changed, 202 insertions(+), 32 deletions(-)

diff --git a/lib/client.dart b/lib/client.dart
index 5bad929..fe2ad7c 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.from(reply);
						return true;
					}
				});
@@ -1204,6 +1202,80 @@ class ClientMessage extends IrcMessage {
		return null;
	}
}
abstract class PrivmsgLike extends IrcMessage {
	final String target;
	final String messageContent;

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

	PrivmsgLike._(IrcMessage msg) :
		this(msg.cmd, msg.params, tags: msg.tags, source: msg.source);

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

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

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

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

	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 {
	Notice.from(super.msg) :
		assert(msg.cmd == 'NOTICE'),
		super._();

	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 {
	final NamesReply names;
diff --git a/lib/client_controller.dart b/lib/client_controller.dart
index 025abea..44fa1dd 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,48 @@ 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) {
			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()),
				));
			} else if (msg.cmd == 'NOTICE' || msg.cmd == 'PRIVMSG') {
				privmsgs.add(PrivmsgLike.from(msg));
			}
		}

		var messageEntries = privmsgs.map((msg) => MessageEntry(msg, buf!.id)).toList();
		if (messageEntries.isEmpty && reactions.isEmpty) {
			return;
		}

		// The empty string is the prefix of all strings, and is ordered first.
		String t = '';
		if (messageEntries.isNotEmpty) {
			await _db.storeMessages(messageEntries);
		}
		if (reactions.isNotEmpty) {
			await _db.storeOrUpdateReactions(reactions);
		}

		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 +790,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 edd45bd..87b487d 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 0f956a7..d11c22b 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();
	}
@@ -561,6 +603,21 @@ class MessageModel {
			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!;
	IrcMessage get msg => entry.msg;
}
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 664b445..617760b 100644
--- a/lib/widget/composer.dart
+++ b/lib/widget/composer.dart
@@ -81,11 +81,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) {
@@ -103,26 +103,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));
		}
@@ -178,7 +178,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 v3 3/6] 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  | 555 ++++++++++++++++++++++++++++++++++
 lib/widget/message_sheet.dart |   5 +-
 4 files changed, 610 insertions(+), 373 deletions(-)
 create mode 100644 lib/widget/message_item.dart

diff --git a/lib/models.dart b/lib/models.dart
index d11c22b..a5fae6b 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..73550d0
--- /dev/null
+++ b/lib/widget/message_item.dart
@@ -0,0 +1,555 @@
import 'package:flutter/material.dart';
import 'package:goguma/models.dart';
import 'package:goguma/widget/message_sheet.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.watch<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.watch<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.watch<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
				),
			),
		);

		decoratedMessage = GestureDetector(
			onLongPress: () {
				MessageSheet.open(context, msg, onSwipe);
			},
			child: decoratedMessage,
		);

		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..73296b7 100644
--- a/lib/widget/message_sheet.dart
+++ b/lib/widget/message_sheet.dart
@@ -14,7 +14,8 @@ class MessageSheet extends StatelessWidget {

	const MessageSheet({ super.key, required this.message, this.onReply });

	static void open(BuildContext context, BufferModel buffer, MessageModel message, VoidCallback? onReply) {
	static void open(BuildContext context, MessageModel message, VoidCallback? onReply) {
		var buffer = context.read<BufferModel>();
		showModalBottomSheet<void>(
			context: context,
			showDragHandle: true,
@@ -59,7 +60,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 v3 4/6] 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 a5fae6b..c13f81a 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 73550d0..289dee2 100644
--- a/lib/widget/message_item.dart
+++ b/lib/widget/message_item.dart
@@ -309,6 +309,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;

@@ -323,7 +326,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: () {
@@ -370,6 +373,53 @@ class _DescriptiveMessageItem extends ConcreteMessageItem {
			child: decoratedMessage,
		);

		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 v3 5/6] 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              | 158 +++++++++++++----------
 lib/client_controller.dart   |  12 +-
 lib/database.dart            |   6 +
 lib/models.dart              |   7 +-
 lib/widget/composer.dart     |  37 +++---
 lib/widget/message_item.dart | 235 ++++++++++++++++++++++++++---------
 6 files changed, 298 insertions(+), 157 deletions(-)

diff --git a/lib/client.dart b/lib/client.dart
index fe2ad7c..4db07be 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.from(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,13 +1200,39 @@ 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(super.cmd, super.params, { super.tags, super.source }) :
		assert(params.length == 2),
		target = params[0],
		messageContent = params[1],
		super();

diff --git a/lib/client_controller.dart b/lib/client_controller.dart
index 44fa1dd..7584cad 100644
--- a/lib/client_controller.dart
+++ b/lib/client_controller.dart
@@ -743,16 +743,8 @@ class ClientController {
		List<PrivmsgLike> privmsgs = [];
		List<ReactionEntry> reactions = [];
		for (var msg in messages) {
			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));
			} else if (msg.cmd == 'NOTICE' || msg.cmd == 'PRIVMSG') {
				privmsgs.add(PrivmsgLike.from(msg));
			}
diff --git a/lib/database.dart b/lib/database.dart
index 87b487d..cddfdc3 100644
--- a/lib/database.dart
+++ b/lib/database.dart
@@ -251,6 +251,12 @@ class ReactionEntry {
		required 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 c13f81a..3e748cc 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 617760b..5f93718 100644
--- a/lib/widget/composer.dart
+++ b/lib/widget/composer.dart
@@ -124,27 +124,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 289dee2..319c9c4 100644
--- a/lib/widget/message_item.dart
+++ b/lib/widget/message_item.dart
@@ -46,7 +46,7 @@ class MessageItemContainer extends StatelessWidget {

		// What if we have a non-action CTCP

		ConcreteMessageItem item;
		StatelessConcreteMessageItem item;
		if (compact) {
			item = _CompactMessageItem(
				key: key,
@@ -96,11 +96,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;
@@ -114,13 +114,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;
@@ -128,10 +121,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;
@@ -266,7 +283,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;
@@ -311,7 +328,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;

@@ -326,7 +342,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: () {
@@ -374,50 +390,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)) {
@@ -438,6 +416,141 @@ 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(
					// Material design states that a button needs a 48x48 margin in order
					// to be accessibly tappable.
					//
					// However, 2x48 leads to too much of a vertical spacing between chips.
					// Instead we believe that `margin / 2` is 'good enough' for the chips
					// that are primarily cosmetic.
					materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
					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.8,
				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({
@@ -497,7 +610,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

[PATCH v3 6/6] add emoji keyboard Export this patch

---
 lib/page/buffer.dart                         |  43 ++++---
 lib/widget/composer.dart                     | 120 ++++++++++++++++---
 lib/widget/message_item.dart                 | 114 ++++++++++--------
 lib/widget/message_sheet.dart                |  15 ++-
 linux/flutter/generated_plugin_registrant.cc |   4 +
 linux/flutter/generated_plugins.cmake        |   1 +
 pubspec.lock                                 |   8 ++
 pubspec.yaml                                 |   1 +
 8 files changed, 217 insertions(+), 89 deletions(-)

diff --git a/lib/page/buffer.dart b/lib/page/buffer.dart
index 59839d9..6e63f2f 100644
--- a/lib/page/buffer.dart
+++ b/lib/page/buffer.dart
@@ -424,11 +424,14 @@ class _BufferPageState extends State<BufferPage> with WidgetsBindingObserver, Si

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

					VoidCallback? onSwipe;
					VoidCallback? reply;
					VoidCallback? addReact;
					if (isChannel && canSendMessage) {
						onSwipe = () {
							_composerKey.currentState!.replyTo(msg);
						};
						reply = () => _composerKey.currentState!.replyTo(msg);
						// TODO check CLIENTTAGDENY ISUPPORT
						if (client.caps.enabled.contains('message-tags')) {
							addReact = () => _composerKey.currentState!.reactTo(msg);
						}
					}
					return _BufferItem(
						key: key,
@@ -437,7 +440,8 @@ class _BufferPageState extends State<BufferPage> with WidgetsBindingObserver, Si
						nextMsg: nextMsg,
						compact: compact,
						unreadMarkerTime: widget.unreadMarkerTime,
						onSwipe: onSwipe,
						reply: reply,
						addReact: addReact,
						onMsgRefTap: _handleMsgRefTap,
						blinking: (index == _blinkMsgIndex),
						opacity: _blinkMsgController,
@@ -539,22 +543,12 @@ class _BufferPageState extends State<BufferPage> with WidgetsBindingObserver, Si
					msgList,
					if (jumpToBottom != null) jumpToBottom,
				])),
			])),
			bottomNavigationBar: Visibility(
				visible: canSendMessage,
				maintainState: true,
				child: Padding(
					// Hack to keep the bottomNavigationBar displayed when the
					// virtual keyboard shows up
					padding: EdgeInsets.only(
						bottom: MediaQuery.of(context).viewInsets.bottom,
					),
					child: Material(elevation: 15, child: Container(
						padding: EdgeInsets.all(10),
						child: Composer(key: _composerKey),
					)),
				Visibility(
					visible: canSendMessage,
					maintainState: true,
					child: Material(elevation: 15, child: Composer(key: _composerKey)),
				),
			),
			])),
		);
	}
}
@@ -567,7 +561,8 @@ class _BufferItem extends StatelessWidget {
	final MessageModel msg;
	final MessageModel? prevMsg, nextMsg;
	final String? unreadMarkerTime;
	final VoidCallback? onSwipe;
	final VoidCallback? reply;
	final VoidCallback? addReact;
	final void Function(int)? onMsgRefTap;
	final bool compact;
	final bool blinking;
@@ -581,7 +576,8 @@ class _BufferItem extends StatelessWidget {
		this.prevMsg,
		this.nextMsg,
		this.unreadMarkerTime,
		this.onSwipe,
		this.reply,
		this.addReact,
		this.onMsgRefTap,
		required this.opacity,
	});
@@ -609,7 +605,8 @@ class _BufferItem extends StatelessWidget {
			key: key,
			msg: msg,
			unreadMarkerTime: unreadMarkerTime,
			onSwipe: onSwipe,
			reply: reply,
			addReact: addReact,
			onMsgRefTap: onMsgRefTap,
			compact: compact,
			isFirstInGroup: showUnreadMarker || !prevMsgSameSender || (prevMsgIsAction != isAction),
diff --git a/lib/widget/composer.dart b/lib/widget/composer.dart
index 5f93718..cfcc4a7 100644
--- a/lib/widget/composer.dart
+++ b/lib/widget/composer.dart
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_flipped_autocomplete/flutter_flipped_autocomplete.dart';
@@ -33,6 +34,8 @@ class ComposerState extends State<Composer> {
	bool _locationServiceAvailable = false;
	bool _addMenuLoading = false;

	bool _isReacting = false;

	DateTime? _ownTyping;
	String? _replyPrefix;
	MessageModel? _replyTo;
@@ -115,7 +118,7 @@ class ComposerState extends State<Composer> {
		return messages;
	}

	void _send(List<PrivmsgLike> messages) async {
	void _sendPrivmsgs(List<PrivmsgLike> messages) async {
		var buffer = context.read<BufferModel>();
		var client = context.read<Client>();
		var db = context.read<DB>();
@@ -151,6 +154,41 @@ class ComposerState extends State<Composer> {
		}
	}

	// TODO duplicate code in MessageItem. deduplicate to a shared controller
	Future<void> _sendReaction(String reaction) 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>();

		var message = client.sendTargetedMessage(
			TagMsg.reaction(
				target: _replyTo!.msg.target,
				inReplyTo: _replyTo!.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);
		}
	}

	void _submitCommand(String text) {
		String name;
		String? param;
@@ -182,7 +220,7 @@ class ComposerState extends State<Composer> {
		if (msgText != null) {
			var buffer = context.read<BufferModel>();
			var msg = Privmsg(buffer.name, msgText);
			_send([msg]);
			_sendPrivmsgs([msg]);
		}
	}

@@ -222,7 +260,7 @@ class ComposerState extends State<Composer> {
			}
		}

		_send(messages);
		_sendPrivmsgs(messages);
		return true;
	}

@@ -357,6 +395,13 @@ class ComposerState extends State<Composer> {
		return notify;
	}

	void reactTo(MessageModel msg) {
		setState(() {
			_replyTo = msg;
			_isReacting = true;
		});
	}

	void replyTo(MessageModel msg) {
		var buffer = context.read<BufferModel>();

@@ -519,8 +564,7 @@ class ComposerState extends State<Composer> {
		);
	}

	@override
	Widget build(BuildContext context) {
	Widget _buildTextComposer() {
		var fab = FloatingActionButton(
			onPressed: () {
				_submit();
@@ -573,17 +617,61 @@ class ComposerState extends State<Composer> {
			);
		}

		return Form(key: _formKey, child: Row(children: [
			Expanded(child: RawFlippedAutocomplete(
				optionsBuilder: _buildOptions,
				displayStringForOption: _displayStringForOption,
				fieldViewBuilder: _buildTextField,
				focusNode: _focusNode,
				textEditingController: _controller,
				optionsViewBuilder: _buildOptionsView,
		return Container(
			padding: EdgeInsets.all(10),
			child: Form(key: _formKey, child: Row(
				children: [
					Expanded(child: RawFlippedAutocomplete(
						optionsBuilder: _buildOptions,
						displayStringForOption: _displayStringForOption,
						fieldViewBuilder: _buildTextField,
						focusNode: _focusNode,
						textEditingController: _controller,
						optionsViewBuilder: _buildOptionsView,
					)),
					if (addMenu != null) addMenu,
					fab,
				],
			))
		);
	}

	Widget _buildEmojiKeyboard() {
		var theme = Theme.of(context);
		return PopScope(
			canPop: false,
			onPopInvoked: (bool didPop) {
				if (didPop) {
					return;
				}
				// close emoji keyboard
				setState(() {
					_isReacting = false;
					_replyTo = null;
				});
			},
			child: SizedBox(height: 250, child: EmojiPicker(
				onEmojiSelected: (_, Emoji emoji) {
					_sendReaction(emoji.emoji);
					setState(() {
						_isReacting = false;
						_replyTo = null;
					});
				},
				onBackspacePressed: null,
				config: Config(
					bgColor: theme.colorScheme.surface,
					skinToneDialogBgColor: theme.colorScheme.surfaceVariant,
				),
			)),
			if (addMenu != null) addMenu,
			fab,
		]));
		);
	}

	@override
	Widget build(BuildContext context) {
		if (_isReacting) {
			return _buildEmojiKeyboard();
		}
		return _buildTextComposer();
	}
}
diff --git a/lib/widget/message_item.dart b/lib/widget/message_item.dart
index 319c9c4..403933c 100644
--- a/lib/widget/message_item.dart
+++ b/lib/widget/message_item.dart
@@ -19,7 +19,8 @@ class MessageItemContainer extends StatelessWidget {
		final MessageModel msg;
		final bool compact;
		final String? unreadMarkerTime;
		final VoidCallback? onSwipe;
		final VoidCallback? reply;
		final VoidCallback? addReact;
		final MsgRefTapAction? onMsgRefTap;
		final bool isFirstInGroup;
		final bool last;
@@ -33,7 +34,8 @@ class MessageItemContainer extends StatelessWidget {
		required this.last,
		required this.showTime,
		this.unreadMarkerTime,
		this.onSwipe,
		this.reply,
		this.addReact,
		this.onMsgRefTap,
	});

@@ -60,7 +62,7 @@ class MessageItemContainer extends StatelessWidget {
				key: key,
				msg: msg,
				ctcp: ctcp,
				onSwipe: onSwipe,
				reply: reply,
				isFirstInGroup: isFirstInGroup,
				last: last,
				showTime: showTime
@@ -69,7 +71,8 @@ class MessageItemContainer extends StatelessWidget {
			item = _DescriptiveMessageItem(
				key: key,
				msg: msg,
				onSwipe: onSwipe,
				reply: reply,
				addReact: addReact,
				onMsgRefTap: onMsgRefTap,
				isFirstInGroup: isFirstInGroup,
				last: last,
@@ -151,7 +154,7 @@ abstract class StatelessConcreteMessageItem extends StatelessWidget with Concret
class _CTCPActionMessageItem extends StatelessConcreteMessageItem {
	final CtcpMessage ctcp;
	final bool showTime;
	final VoidCallback? onSwipe;
	final VoidCallback? reply;

	const _CTCPActionMessageItem ({
		super.key,
@@ -160,7 +163,7 @@ class _CTCPActionMessageItem extends StatelessConcreteMessageItem {
		required super.isFirstInGroup,
		required super.last,
		required this.showTime,
		this.onSwipe,
		this.reply,
	});

	@override
@@ -234,7 +237,7 @@ class _CTCPActionMessageItem extends StatelessConcreteMessageItem {
						child: Icon(Icons.reply),
					),
				),
				onSwipe: onSwipe,
				onSwipe: reply,
			);
		}

@@ -285,7 +288,8 @@ class _LinkPreviewContainer extends StatelessWidget {
/// A single descriptive message bubble.
class _DescriptiveMessageItem extends StatelessConcreteMessageItem {
	final bool showTime;
	final VoidCallback? onSwipe;
	final VoidCallback? reply;
	final VoidCallback? addReact;
	final MsgRefTapAction? onMsgRefTap;

	const _DescriptiveMessageItem({
@@ -294,7 +298,8 @@ class _DescriptiveMessageItem extends StatelessConcreteMessageItem {
		required super.isFirstInGroup,
		required super.last,
		required this.showTime,
		this.onSwipe,
		this.addReact,
		this.reply,
		this.onMsgRefTap,
	});

@@ -384,7 +389,7 @@ class _DescriptiveMessageItem extends StatelessConcreteMessageItem {

		decoratedMessage = GestureDetector(
			onLongPress: () {
				MessageSheet.open(context, msg, onSwipe);
				MessageSheet.open(context, msg, reply, addReact);
			},
			child: decoratedMessage,
		);
@@ -393,7 +398,7 @@ class _DescriptiveMessageItem extends StatelessConcreteMessageItem {
			decoratedMessage = Column(
				children: [
					decoratedMessage,
					_ReactionsWidget(msg: msg)
					_ReactionsWidget(msg: msg, openKeyboard: addReact,)
				]
			);
		}
@@ -408,7 +413,7 @@ class _DescriptiveMessageItem extends StatelessConcreteMessageItem {
						child: Icon(Icons.reply),
					),
				),
				onSwipe: onSwipe,
				onSwipe: reply,
			);
		}

@@ -423,11 +428,13 @@ class _ReactionsWidget extends StatefulWidget with ConcreteMessageItem {
	bool get last => false;
	@override
	bool get isFirstInGroup => false;
	final VoidCallback? openKeyboard;


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

	@override
@@ -475,44 +482,57 @@ class _ReactionsWidgetState extends State<_ReactionsWidget> {
	Widget build(BuildContext context) {
		var theme = Theme.of(context);

		var reactionChips = widget.msg.reactionCounts.entries.map<Widget>((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(
				// Material design states that a button needs a 48x48 margin in order
				// to be accessibly tappable.
				//
				// However, 2x48 leads to too much of a vertical spacing between chips.
				// Instead we believe that `margin / 2` is 'good enough' for the chips
				// that are primarily cosmetic.
				materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
				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();
		// now we add the 'add emoji' button
		reactionChips.add(
			ActionChip(
				materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
				label: Icon(Icons.add_reaction_outlined),
				labelStyle: theme.textTheme.labelMedium,
				//selected: false,
				shape: ReactionChipBorder(selectedColor: theme.colorScheme.outline),
				onPressed: widget.openKeyboard,
			),
		);

		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(
					// Material design states that a button needs a 48x48 margin in order
					// to be accessibly tappable.
					//
					// However, 2x48 leads to too much of a vertical spacing between chips.
					// Instead we believe that `margin / 2` is 'good enough' for the chips
					// that are primarily cosmetic.
					materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
					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(),
			children: reactionChips,
			alignment: widget.wrapAlignment(context),
		);
		// We want our reactions to take up half the screen at most.
diff --git a/lib/widget/message_sheet.dart b/lib/widget/message_sheet.dart
index 73296b7..78e26a0 100644
--- a/lib/widget/message_sheet.dart
+++ b/lib/widget/message_sheet.dart
@@ -11,10 +11,11 @@ import '../page/buffer.dart';
class MessageSheet extends StatelessWidget {
	final MessageModel message;
	final VoidCallback? onReply;
	final VoidCallback? onAddReact;

	const MessageSheet({ super.key, required this.message, this.onReply });
	const MessageSheet({ super.key, required this.message, this.onReply, this.onAddReact });

	static void open(BuildContext context, MessageModel message, VoidCallback? onReply) {
	static void open(BuildContext context, MessageModel message, VoidCallback? onReply, VoidCallback? onAddReact) {
		var buffer = context.read<BufferModel>();
		showModalBottomSheet<void>(
			context: context,
@@ -27,7 +28,7 @@ class MessageSheet extends StatelessWidget {
						ChangeNotifierProvider<NetworkModel>.value(value: buffer.network),
						Provider<Client>.value(value: client),
					],
					child: MessageSheet(message: message, onReply: onReply),
					child: MessageSheet(message: message, onReply: onReply, onAddReact: onAddReact),
				);
			},
		);
@@ -47,6 +48,14 @@ class MessageSheet extends StatelessWidget {
					onReply!();
				},
			),
			if (onAddReact != null) ListTile(
				title: Text('React'),
				leading: Icon(Icons.add_reaction_outlined),
				onTap: () {
					Navigator.pop(context);
					onAddReact!();
				},
			),
			if (!client.isMyNick(sender)) ListTile(
				title: Text('Message $sender'),
				leading: Icon(Icons.chat_bubble),
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
index fa56afe..1a281e9 100644
--- a/linux/flutter/generated_plugin_registrant.cc
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -6,10 +6,14 @@

#include "generated_plugin_registrant.h"

#include <emoji_picker_flutter/emoji_picker_flutter_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <webcrypto/webcrypto_plugin.h>

void fl_register_plugins(FlPluginRegistry* registry) {
  g_autoptr(FlPluginRegistrar) emoji_picker_flutter_registrar =
      fl_plugin_registry_get_registrar_for_plugin(registry, "EmojiPickerFlutterPlugin");
  emoji_picker_flutter_plugin_register_with_registrar(emoji_picker_flutter_registrar);
  g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
      fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
  url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index 9f13943..379f175 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#

list(APPEND FLUTTER_PLUGIN_LIST
  emoji_picker_flutter
  url_launcher_linux
  webcrypto
)
diff --git a/pubspec.lock b/pubspec.lock
index ed75e12..44b03a7 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -137,6 +137,14 @@ packages:
      url: "https://pub.dev"
    source: hosted
    version: "3.2.3"
  emoji_picker_flutter:
    dependency: "direct main"
    description:
      name: emoji_picker_flutter
      sha256: "009c51efc763d5a6ba05a5628b8b2184c327cd117d66ea9c3e7edf2ff269c423"
      url: "https://pub.dev"
    source: hosted
    version: "1.6.3"
  fake_async:
    dependency: transitive
    description:
diff --git a/pubspec.yaml b/pubspec.yaml
index ce122b1..490336e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -41,6 +41,7 @@ dependencies:
  scrollable_positioned_list: ^0.3.5
  html: ^0.15.2
  geolocator: ^10.1.0
  emoji_picker_flutter: ^1.6.3

dev_dependencies:
  flutter_lints: ^3.0.0
-- 
2.42.1