From 817289a01d9b0ecd8c2d4a444ca8e0361d898230 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 23 Dec 2024 16:05:07 -0500 Subject: [PATCH] add shared chat session support (#424) * dispose overlay timer * fix pip * add channel indicators to messages for shared chat sessions * add tooltip to chat message pfp * remove tooltip marign/padding * return null instead of throwing error * fix build error --- lib/apis/twitch_api.dart | 23 +++++++++++ lib/models/irc.dart | 40 +++++++++++++++++++ lib/models/shared_chat_session.dart | 33 +++++++++++++++ lib/models/shared_chat_session.g.dart | 22 ++++++++++ .../channel/chat/stores/chat_store.dart | 21 ++++++++++ .../channel/chat/widgets/chat_message.dart | 1 + lib/screens/channel/video/video_store.dart | 2 + lib/theme.dart | 2 - 8 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 lib/models/shared_chat_session.dart create mode 100644 lib/models/shared_chat_session.g.dart diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 6494fd0c..eea8be57 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -6,6 +6,7 @@ import 'package:frosty/models/badges.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; import 'package:http/http.dart'; @@ -540,6 +541,28 @@ class TwitchApi { } } + Future getSharedChatSession({ + required String broadcasterId, + required Map headers, + }) async { + final url = Uri.parse( + 'https://api.twitch.tv/helix/shared_chat/session?broadcaster_id=$broadcasterId', + ); + + final response = await _client.get(url, headers: headers); + if (response.statusCode == 200) { + final sessionData = jsonDecode(response.body)['data'] as List; + + if (sessionData.isEmpty) { + return null; + } + + return SharedChatSession.fromJson(sessionData.first); + } else { + return Future.error('Failed to get shared chat session info'); + } + } + // Unblocks the user with the given ID and returns true on success or false on failure. Future> getRecentMessages({ required String userLogin, diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 11b0e148..d83d0306 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -6,6 +7,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils.dart'; @@ -121,6 +123,7 @@ class IRCMessage { void Function(String)? onTapPingedUser, bool showMessage = true, bool useReadableColors = false, + Map? channelIdToUserTwitch, TimestampType timestamp = TimestampType.disabled, }) { final isLightTheme = Theme.of(context).brightness == Brightness.light; @@ -167,6 +170,43 @@ class IRCMessage { } } + final sourceChannelId = tags['source-room-id'] ?? tags['room-id']; + final sourceChannelUser = channelIdToUserTwitch != null + ? channelIdToUserTwitch[sourceChannelId] + : null; + if (sourceChannelUser != null) { + span.add( + WidgetSpan( + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + preferBelow: false, + message: sourceChannelUser.displayName, + child: CachedNetworkImage( + imageUrl: sourceChannelUser.profileImageUrl, + imageBuilder: (context, imageProvider) => Container( + width: badgeSize, + height: badgeSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: + DecorationImage(image: imageProvider, fit: BoxFit.cover), + ), + ), + placeholder: (context, url) => Container( + width: badgeSize, + height: badgeSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey, + ), + ), + ), + ), + ), + ); + span.add(const TextSpan(text: ' ')); + } + // Indicator to skip adding the bot badges later when adding the rest of FFZ badges. var skipBot = false; diff --git a/lib/models/shared_chat_session.dart b/lib/models/shared_chat_session.dart new file mode 100644 index 00000000..fc5c90b5 --- /dev/null +++ b/lib/models/shared_chat_session.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'shared_chat_session.g.dart'; + +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class SharedChatSession { + final String sessionId; + final String hostBroadcasterId; + final List participants; + final String createdAt; + final String updatedAt; + + SharedChatSession({ + required this.sessionId, + required this.hostBroadcasterId, + required this.participants, + required this.createdAt, + required this.updatedAt, + }); + + factory SharedChatSession.fromJson(Map json) => + _$SharedChatSessionFromJson(json); +} + +@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) +class Participant { + final String broadcasterId; + + Participant({required this.broadcasterId}); + + factory Participant.fromJson(Map json) => + _$ParticipantFromJson(json); +} diff --git a/lib/models/shared_chat_session.g.dart b/lib/models/shared_chat_session.g.dart new file mode 100644 index 00000000..44a4acfb --- /dev/null +++ b/lib/models/shared_chat_session.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shared_chat_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SharedChatSession _$SharedChatSessionFromJson(Map json) => + SharedChatSession( + sessionId: json['session_id'] as String, + hostBroadcasterId: json['host_broadcaster_id'] as String, + participants: (json['participants'] as List) + .map((e) => Participant.fromJson(e as Map)) + .toList(), + createdAt: json['created_at'] as String, + updatedAt: json['updated_at'] as String, + ); + +Participant _$ParticipantFromJson(Map json) => Participant( + broadcasterId: json['broadcaster_id'] as String, + ); diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 7148990f..09ccf54b 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -6,6 +6,7 @@ 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'; +import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -152,6 +153,8 @@ abstract class ChatStoreBase with Store { @observable IRCMessage? replyingToMessage; + final channelIdToUserTwitch = ObservableMap(); + ChatStoreBase({ required this.twitchApi, required this.auth, @@ -204,6 +207,24 @@ abstract class ChatStoreBase with Store { assetsStore.init(); + // Get the shared chat session and the profile pictures of the participants. + twitchApi + .getSharedChatSession( + broadcasterId: channelId, + headers: auth.headersTwitch, + ) + .then((sharedChatSession) { + if (sharedChatSession == null) return; + + for (final participant in sharedChatSession.participants) { + twitchApi + .getUser(id: participant.broadcasterId, headers: auth.headersTwitch) + .then((user) { + channelIdToUserTwitch[participant.broadcasterId] = user; + }); + } + }); + _messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); if (settings.showVideo && settings.chatDelay > 0) { diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 72189689..01249a3d 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -173,6 +173,7 @@ class ChatMessage extends StatelessWidget { useReadableColors: chatStore.settings.useReadableColors, launchExternal: chatStore.settings.launchUrlExternal, timestamp: chatStore.settings.timestampType, + channelIdToUserTwitch: chatStore.channelIdToUserTwitch, ), ), ); diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 3a74bfd9..079432bb 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -487,6 +487,8 @@ abstract class VideoStoreBase with Store { }); } + _overlayTimer.cancel(); + _disposeOverlayReaction(); _disposeAndroidAutoPipReaction?.call(); } diff --git a/lib/theme.dart b/lib/theme.dart index 1f4f6752..570c1da0 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -71,8 +71,6 @@ class FrostyThemes { tabAlignment: TabAlignment.start, ), tooltipTheme: TooltipThemeData( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - margin: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: backgroundColor, borderRadius: const BorderRadius.all(Radius.circular(8)),