The feature is optional & disabled by default.
+typing=paused is not supported in this implementation.
---
Updated based on your comments.
The typing indicator in the subtitle mirrors Whatsapp's behavior and is
easier to implement than some text at the bottom of the screen.
lib/client_controller.dart | 10 ++++++++-
lib/models.dart | 21 +++++++++++++++++++
lib/page/buffer.dart | 42 ++++++++++++++++++++++++++++++++++++++
lib/page/settings.dart | 15 ++++++++++++++
4 files changed, 87 insertions(+), 1 deletion(-)
diff --git a/lib/client_controller.dart b/lib/client_controller.dart
index 1497a60..05f1f77 100644
--- a/lib/client_controller.dart
+++ b/lib/client_controller.dart
@@ -450,7 +450,7 @@ class ClientController {
}
var buffer = _bufferList.get(msg.source.name, network);
- if(buffer != null) {
+ if (buffer != null) {
buffer.realname = realname;
_db.storeBuffer(buffer.entry);
}
@@ -496,6 +496,7 @@ class ClientController {
break;
case 'PRIVMSG':
case 'NOTICE':
+ case 'TAGMSG':
var target = msg.params[0];
if (msg.batchByType('chathistory') != null) {
break;
@@ -505,6 +506,13 @@ class ClientController {
if (!client.isChannel(target) && !client.isMyNick(msg.source.name)) {
target = msg.source.name;
}
+ if (msg.cmd == 'TAGMSG') {
+ var typing = msg.tags['+typing'];
+ if (typing != null && !client.isMyNick(msg.source.name)) {
+ _bufferList.get(target, network)?.setTyping(msg.source.name, typing == 'active');
+ }
+ break;
+ }
return _handleChatMessages(target, [msg]);
case 'INVITE':
var nickname = msg.params[0];
diff --git a/lib/models.dart b/lib/models.dart
index f2063ff..7808622 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
@@ -360,6 +361,7 @@ class BufferModel extends ChangeNotifier {
String? _lastDeliveredTime;
bool _messageHistoryLoaded = false;
List<MessageModel> _messages = [];
+ Map<String, Timer> _typing = {};
// Kept in sync by BufferPageState
bool focused = false;
@@ -481,6 +483,25 @@ class BufferModel extends ChangeNotifier {
notifyListeners();
return true;
}
+
+ List<String> get typing {
+ var typing = _typing.keys.toList();
+ typing.sort();
+ return typing;
+ }
+
+ void setTyping(String member, bool typing) {
+ _typing[member]?.cancel();
+ if (typing) {
+ _typing[member] = Timer(Duration(seconds: 6), () {
+ _typing.remove(member);
+ notifyListeners();
+ });
+ } else {
+ _typing.remove(member);
+ }
+ notifyListeners();
+ }
}
int _compareMessageModels(MessageModel a, MessageModel b) {
diff --git a/lib/page/buffer.dart b/lib/page/buffer.dart
index 0e47222..8b620a0 100644
--- a/lib/page/buffer.dart
+++ b/lib/page/buffer.dart
@@ -78,6 +78,8 @@ class BufferPageState extends State<BufferPage> with WidgetsBindingObserver {
bool _showJumpToBottom = false;
+ DateTime? _ownTyping;
+
@override
void initState() {
super.initState();
@@ -111,6 +113,8 @@ class BufferPageState extends State<BufferPage> with WidgetsBindingObserver {
var buffer = context.read<BufferModel>();
var client = context.read<Client>();
+ _setOwnTyping(false);
+
var msg = IrcMessage('PRIVMSG', [buffer.name, text]);
client.send(msg);
@@ -125,6 +129,18 @@ class BufferPageState extends State<BufferPage> with WidgetsBindingObserver {
}
}
+ void _sendTypingStatus() {
+ var buffer = context.read<BufferModel>();
+ var client = context.read<Client>();
+
+ var active = _composerController.text != '';
+ var notify = _setOwnTyping(active);
+ if (notify) {
+ var msg = IrcMessage('TAGMSG', [buffer.name], tags: {'+typing': active ? 'active' : 'done'});
+ client.send(msg);
+ }
+ }
+
void _submitComposer() {
if (_composerController.text != '') {
_send(_composerController.text);
@@ -178,6 +194,21 @@ class BufferPageState extends State<BufferPage> with WidgetsBindingObserver {
}
}
+ bool _setOwnTyping(bool active) {
+ bool notify;
+ var time = DateTime.now();
+ if (!active) {
+ notify = _ownTyping != null && _ownTyping!.add(Duration(seconds: 6)).isAfter(time);
+ _ownTyping = null;
+ } else {
+ notify = _ownTyping == null || _ownTyping!.add(Duration(seconds: 3)).isBefore(time);
+ if (notify) {
+ _ownTyping = time;
+ }
+ }
+ return notify;
+ }
+
@override
void dispose() {
_composerFocusNode.dispose();
@@ -302,6 +333,14 @@ class BufferPageState extends State<BufferPage> with WidgetsBindingObserver {
}
var messages = buffer.messages;
var compact = context.read<SharedPreferences>().getBool('buffer_compact') ?? false;
+ var showTyping = context.read<SharedPreferences>().getBool('typing_indicator') ?? false;
+
+ if (canSendMessage && showTyping) {
+ var typingNicks = buffer.typing;
+ if (typingNicks.isNotEmpty) {
+ subtitle = typingNicks.join(', ') + ' ${typingNicks.length > 1 ? 'are' : 'is'} typing...';
+ }
+ }
Widget? joinBanner;
if (isChannel && !buffer.joined && !buffer.joining) {
@@ -360,6 +399,9 @@ class BufferPageState extends State<BufferPage> with WidgetsBindingObserver {
hintText: 'Write a message...',
border: InputBorder.none,
),
+ onChanged: showTyping ? (value) {
+ _sendTypingStatus();
+ } : null,
onSubmitted: (value) {
_submitComposer();
},
diff --git a/lib/page/settings.dart b/lib/page/settings.dart
index cd90544..cd46f82 100644
--- a/lib/page/settings.dart
+++ b/lib/page/settings.dart
@@ -24,11 +24,13 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
late bool _compact;
+ late bool _typing;
@override
void initState() {
super.initState();
_compact = context.read<SharedPreferences>().getBool('buffer_compact') ?? false;
+ _typing = context.read<SharedPreferences>().getBool('typing_indicator') ?? false;
}
void _logout() {
@@ -120,6 +122,19 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
),
+ ListTile(
+ title: Text('Send & display typing indicators'),
+ leading: Icon(Icons.border_color),
+ trailing: Switch(
+ value: _typing,
+ onChanged: (bool c) {
+ setState(() {
+ _typing = c;
+ context.read<SharedPreferences>().setBool('typing_indicator', c);
+ });
+ },
+ ),
+ ),
Divider(),
ListTile(
title: Text('About'),
base-commit: fc4308af156f0642bc7ec7bac4910356e20bdd74
--
2.17.1