diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 26687c40..74e504c4 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -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> 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'); + } + } } diff --git a/lib/models/irc.dart b/lib/models/irc.dart index b9e44ea1..77c107be 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -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 = {}; 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(' '); diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 0ec28159..a0e0f3bc 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -47,6 +47,7 @@ class _VideoChatState extends State { final _chatKey = GlobalKey(); late final ChatStore _chatStore = ChatStore( + twitchApi: context.read(), channelName: widget.userLogin, channelId: widget.userId, displayName: widget.userName, diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index b6888640..7fc95d9c 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -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 = []; /// 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 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(); diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index 066832fd..d26238d5 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -219,6 +219,14 @@ mixin _$ChatStore on ChatStoreBase, Store { return _$getAssetsAsyncAction.run(() => super.getAssets()); } + late final _$getRecentMessageAsyncAction = + AsyncAction('ChatStoreBase.getRecentMessage', context: context); + + @override + Future getRecentMessage() { + return _$getRecentMessageAsyncAction.run(() => super.getRecentMessage()); + } + late final _$ChatStoreBaseActionController = ActionController(name: 'ChatStoreBase', context: context); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 4bd4308d..92a490d7 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -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, + ), ); }, ); diff --git a/lib/screens/onboarding/onboarding_setup.dart b/lib/screens/onboarding/onboarding_setup.dart index 5a28700f..05419fe8 100644 --- a/lib/screens/onboarding/onboarding_setup.dart +++ b/lib/screens/onboarding/onboarding_setup.dart @@ -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( diff --git a/lib/screens/settings/chat_settings.dart b/lib/screens/settings/chat_settings.dart index 7a7e3992..b8072fc2 100644 --- a/lib/screens/settings/chat_settings.dart +++ b/lib/screens/settings/chat_settings.dart @@ -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 { 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, + ), ], ), ); diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index a5be82f2..82ba6661 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -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 diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index b4cd2d9d..986dd2b0 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -61,6 +61,7 @@ SettingsStore _$SettingsStoreFromJson(Map 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 _$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}