Add option to enable historical recent messages in chat (#374)

* Add initial support for recent messages

* Add setting for recent messages

* Add setting to onboarding

* Activate buffer after connecting

* Update copy

* Remove unnecessay future delayed

* Remove old line

* Prefer message buffer
This commit is contained in:
Tommy Chow 2024-08-12 00:01:45 -07:00 • committed by GitHub
parent a0c945a8d4
commit 1594d40f48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 245 additions and 85 deletions

View file

@ -522,4 +522,20 @@ class TwitchApi {
return false;
}
}
// Unblocks the user with the given ID and returns true on success or false on failure.
Future<List<dynamic>> getRecentMessages({
required String userLogin,
}) async {
final url = Uri.parse(
'https://recent-messages.robotty.de/api/v2/recent-messages/$userLogin',
);
final response = await _client.get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body)['messages'] as List;
} else {
return Future.error('Failed to get recent messages for $userLogin');
}
}
}

View file

@ -797,13 +797,16 @@ class IRCMessage {
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
//
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
var message = splitMessage.length > 3
? splitMessage.sublist(3).join(' ').substring(1)
: null;
String? message;
if (splitMessage.length > 3) {
final combinedMessage = splitMessage.sublist(3).join(' ');
if (combinedMessage.startsWith(':')) {
message = combinedMessage.substring(1);
} else {
message = combinedMessage;
}
}
// Now process any Twitch emotes contained in the message tags.
// The map containing emotes from the user's tags to their URL.
@ -811,7 +814,7 @@ class IRCMessage {
final localEmotes = <String, Emote>{};
final emoteTags = mappedTags['emotes'];
if (emoteTags != null) {
if (emoteTags != null && message != null) {
// Emotes and their indices are separated by '/' so split them there.
final emotes = emoteTags.split('/');
@ -838,7 +841,7 @@ class IRCMessage {
final startIndex = int.parse(indexSplit[0]);
final endIndex = int.parse(indexSplit[1]);
final emoteWord = message!.substring(startIndex, endIndex + 1);
final emoteWord = message.substring(startIndex, endIndex + 1);
// Store the emote word and its associated URL for later use.
localEmotes[emoteWord] = Emote(
@ -869,6 +872,9 @@ class IRCMessage {
// Escape the message
message = message
.split(' ')
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
.map((word) => word.replaceAll('\u{E0000}', '').trim())
.where((element) => element != '')
.join(' ');

View file

@ -47,6 +47,7 @@ class _VideoChatState extends State<VideoChat> {
final _chatKey = GlobalKey();
late final ChatStore _chatStore = ChatStore(
twitchApi: context.read<TwitchApi>(),
channelName: widget.userLogin,
channelId: widget.userId,
displayName: widget.userName,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/models/events.dart';
import 'package:frosty/models/irc.dart';
@ -26,6 +27,8 @@ abstract class ChatStoreBase with Store {
/// The maximum ammount of messages to render when autoscroll is enabled.
static const _renderMessageLimit = 100;
final TwitchApi twitchApi;
/// The amount of messages to free (remove) when the [_messageLimit] is reached.
final _messagesToRemove = (_messageLimit * 0.2).toInt();
@ -84,7 +87,7 @@ abstract class ChatStoreBase with Store {
final reactions = <ReactionDisposer>[];
/// The periodic timer used for batching chat message re-renders.
late final Timer _messageBufferTimer;
Timer? _messageBufferTimer;
/// The list of chat messages to add once autoscroll is resumed.
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
@ -150,6 +153,7 @@ abstract class ChatStoreBase with Store {
IRCMessage? replyingToMessage;
ChatStoreBase({
required this.twitchApi,
required this.auth,
required this.chatDetailsStore,
required this.assetsStore,
@ -198,19 +202,12 @@ abstract class ChatStoreBase with Store {
),
);
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(timer) => addMessages(),
);
assetsStore.init();
messageBuffer
.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
_messages.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
if (settings.showVideo && settings.chatDelay > 0) {
messageBuffer.add(
_messages.add(
IRCMessage.createNotice(
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
@ -221,14 +218,14 @@ abstract class ChatStoreBase with Store {
reactions.add(
reaction((_) => settings.showVideo, (showVideo) {
if (showVideo && settings.chatDelay > 0) {
messageBuffer.add(
_messages.add(
IRCMessage.createNotice(
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
),
);
} else {
messageBuffer.add(
_messages.add(
IRCMessage.createNotice(
message: 'Removing message delay...',
),
@ -237,7 +234,11 @@ abstract class ChatStoreBase with Store {
}),
);
connectToChat();
if (settings.showRecentMessages) {
getRecentMessage().then((_) => connectToChat());
} else {
connectToChat();
}
// Tell the scrollController to determine when auto-scroll should be enabled or disabled.
scrollController.addListener(() {
@ -376,10 +377,17 @@ abstract class ChatStoreBase with Store {
messageBuffer.add(
IRCMessage.createNotice(
message:
"Connected to ${getReadableName(displayName, channelName)}'s chat!",
"Welcome to ${getReadableName(displayName, channelName)}'s chat!",
),
);
// Activate the message buffer.
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200),
(timer) => addMessages(),
);
getAssets().then((_) {
if (!settings.show7TVEmotes) return;
@ -446,53 +454,59 @@ abstract class ChatStoreBase with Store {
_sevenTVChannel =
WebSocketChannel.connect(Uri.parse('wss://events.7tv.io/v3'));
void listener(dynamic data) {
// debugPrint(data);
final decoded = jsonDecode(data);
final event = SevenTVEvent.fromJson(decoded);
final body = event.d.body;
if (event.d.type != 'emote_set.update' || body == null) return;
if (body.pushed != null) {
final pushedEmote = body.pushed?.first.value;
if (pushedEmote == null) return;
final emote = Emote.from7TV(pushedEmote, EmoteType.sevenTVChannel);
assetsStore.emoteToObject[emote.name] = emote;
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} added 7TV emote "${emote.name}" to chat',
),
);
} else if (body.pulled != null) {
final pulledEmote = body.pulled?.first.oldValue;
if (pulledEmote == null) return;
assetsStore.emoteToObject.removeWhere(
(name, _) => name == pulledEmote.name,
);
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} removed 7TV emote "${pulledEmote.name}" from chat',
),
);
}
}
_sevenTVChannel?.stream.listen(
(data) => Future.delayed(
settings.showVideo
? Duration(seconds: settings.chatDelay.toInt())
: Duration.zero,
() {
// debugPrint(data);
final decoded = jsonDecode(data);
final event = SevenTVEvent.fromJson(decoded);
final body = event.d.body;
if (event.d.type != 'emote_set.update' || body == null) return;
if (body.pushed != null) {
final pushedEmote = body.pushed?.first.value;
if (pushedEmote == null) return;
final emote = Emote.from7TV(pushedEmote, EmoteType.sevenTVChannel);
assetsStore.emoteToObject[emote.name] = emote;
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} added 7TV emote "${emote.name}" to chat',
),
);
} else if (body.pulled != null) {
final pulledEmote = body.pulled?.first.oldValue;
if (pulledEmote == null) return;
assetsStore.emoteToObject.removeWhere(
(name, _) => name == pulledEmote.name,
);
messageBuffer.add(
IRCMessage.createNotice(
message:
'${getReadableName(body.actor.displayName, body.actor.username)} removed 7TV emote "${pulledEmote.name}" from chat',
),
);
}
},
),
(data) {
if (!settings.showVideo || settings.chatDelay == 0) {
listener(data);
} else {
Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => listener(data),
);
}
},
onError: (error) => debugPrint('7TV events error: ${error.toString()}'),
onDone: () => debugPrint('7TV events done'),
);
@ -508,12 +522,16 @@ abstract class ChatStoreBase with Store {
// Listen for new messages and forward them to the handler.
_channelListener = _channel?.stream.listen(
(data) => Future.delayed(
settings.showVideo
? Duration(seconds: settings.chatDelay.toInt())
: Duration.zero,
() => _handleIRCData(data.toString()),
),
(data) {
if (!settings.showVideo || settings.chatDelay == 0) {
_handleIRCData(data.toString());
} else {
Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString()),
);
}
},
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onDone: () async {
if (_shouldDisconnect) {
@ -720,11 +738,21 @@ abstract class ChatStoreBase with Store {
timeRemaining = const Duration();
}
@action
Future<void> getRecentMessage() async {
final recentMessages =
await twitchApi.getRecentMessages(userLogin: channelName);
for (final message in recentMessages) {
_handleIRCData(message);
}
}
/// Closes and disposes all the channels and controllers used by the store.
void dispose() {
_shouldDisconnect = true;
_messageBufferTimer.cancel();
_messageBufferTimer?.cancel();
_notificationTimer?.cancel();
sleepTimer?.cancel();

View file

@ -219,6 +219,14 @@ mixin _$ChatStore on ChatStoreBase, Store {
return _$getAssetsAsyncAction.run(() => super.getAssets());
}
late final _$getRecentMessageAsyncAction =
AsyncAction('ChatStoreBase.getRecentMessage', context: context);
@override
Future<void> getRecentMessage() {
return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage());
}
late final _$ChatStoreBaseActionController =
ActionController(name: 'ChatStoreBase', context: context);

View file

@ -440,15 +440,20 @@ class ChatMessage extends StatelessWidget {
child: dividedMessage,
);
return InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
}
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: coloredMessage,
final isHistorical = ircMessage.tags['historical'] == '1';
return Opacity(
opacity: isHistorical ? 0.5 : 1,
child: InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
}
},
onLongPress: () => onLongPressMessage(context, defaultTextStyle),
child: coloredMessage,
),
);
},
);

View file

@ -1,6 +1,7 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/onboarding/onboarding_scaffold.dart';
@ -9,6 +10,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
class OnboardingSetup extends StatelessWidget {
const OnboardingSetup({super.key});
@ -32,6 +34,37 @@ class OnboardingSetup extends StatelessWidget {
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
SettingsListSwitch(
title: 'Show historical recent messages',
subtitle: Text.rich(
TextSpan(
text:
'Loads historical recent messages in chat through a third-party API service at ',
children: [
TextSpan(
text: 'https://recent-messages.robotty.de/',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
Uri.parse(
'https://recent-messages.robotty.de/',
),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
),
),
],
),
),
value: settingsStore.showRecentMessages,
onChanged: (newValue) =>
settingsStore.showRecentMessages = newValue,
),
SettingsListSwitch(
title: 'Share crash logs and analytics',
subtitle: const Text(

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
@ -9,6 +10,7 @@ import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:url_launcher/url_launcher.dart';
class ChatSettings extends StatefulWidget {
final SettingsStore settingsStore;
@ -324,6 +326,38 @@ class _ChatSettingsState extends State<ChatSettings> {
value: settingsStore.showFFZBadges,
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
),
const SectionHeader('Recent messages', showDivider: true),
SettingsListSwitch(
title: 'Show historical recent messages',
subtitle: Text.rich(
TextSpan(
text:
'Loads historical recent messages in chat through a third-party API service at ',
children: [
TextSpan(
text: 'https://recent-messages.robotty.de/',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
decorationColor: Colors.blue,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(
Uri.parse(
'https://recent-messages.robotty.de/',
),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
),
),
],
),
),
value: settingsStore.showRecentMessages,
onChanged: (newValue) =>
settingsStore.showRecentMessages = newValue,
),
],
),
);

View file

@ -150,6 +150,9 @@ abstract class _SettingsStoreBase with Store {
static const defaultShowFFZEmotes = true;
static const defaultShowFFZBadges = true;
// Recent messages defaults
static const defaultShowRecentMessages = false;
// Message sizing options
@JsonKey(defaultValue: defaultBadgeScale)
@observable
@ -282,6 +285,11 @@ abstract class _SettingsStoreBase with Store {
@observable
var showFFZBadges = defaultShowFFZBadges;
// Recent messages
@JsonKey(defaultValue: defaultShowRecentMessages)
@observable
var showRecentMessages = defaultShowRecentMessages;
@action
void resetChatSettings() {
badgeScale = defaultBadgeScale;
@ -321,6 +329,8 @@ abstract class _SettingsStoreBase with Store {
showBTTVBadges = defaultShowBTTVBadges;
showFFZEmotes = defaultShowFFZEmotes;
showFFZBadges = defaultShowFFZBadges;
showRecentMessages = defaultShowRecentMessages;
}
// * Other settings

View file

@ -61,6 +61,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true
..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true
..showFFZBadges = json['showFFZBadges'] as bool? ?? true
..showRecentMessages = json['showRecentMessages'] as bool? ?? false
..shareCrashLogsAndAnalytics =
json['shareCrashLogsAndAnalytics'] as bool? ?? true
..fullScreen = json['fullScreen'] as bool? ?? false
@ -109,6 +110,7 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'showBTTVBadges': instance.showBTTVBadges,
'showFFZEmotes': instance.showFFZEmotes,
'showFFZBadges': instance.showFFZBadges,
'showRecentMessages': instance.showRecentMessages,
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
'fullScreen': instance.fullScreen,
'fullScreenChatOverlay': instance.fullScreenChatOverlay,
@ -791,6 +793,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
late final _$showRecentMessagesAtom =
Atom(name: '_SettingsStoreBase.showRecentMessages', context: context);
@override
bool get showRecentMessages {
_$showRecentMessagesAtom.reportRead();
return super.showRecentMessages;
}
@override
set showRecentMessages(bool value) {
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () {
super.showRecentMessages = value;
});
}
late final _$shareCrashLogsAndAnalyticsAtom = Atom(
name: '_SettingsStoreBase.shareCrashLogsAndAnalytics', context: context);
@ -953,6 +971,7 @@ showBTTVEmotes: ${showBTTVEmotes},
showBTTVBadges: ${showBTTVBadges},
showFFZEmotes: ${showFFZEmotes},
showFFZBadges: ${showFFZBadges},
showRecentMessages: ${showRecentMessages},
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},
fullScreen: ${fullScreen},
fullScreenChatOverlay: ${fullScreenChatOverlay}