diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 85df78edb..03b3831d0 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -196,7 +196,7 @@ class StreamChatClient { StreamSubscription? _connectionStatusSubscription; - final _eventController = BehaviorSubject(); + final _eventController = PublishSubject(); /// Stream of [Event] coming from [_ws] connection /// Listen to this or use the [on] method to filter specific event types @@ -491,10 +491,12 @@ class StreamChatClient { final previousState = wsConnectionStatus; final currentState = _wsConnectionStatus = status; - handleEvent(Event( - type: EventType.connectionChanged, - online: status == ConnectionStatus.connected, - )); + if (previousState != currentState) { + handleEvent(Event( + type: EventType.connectionChanged, + online: status == ConnectionStatus.connected, + )); + } if (currentState == ConnectionStatus.connected && previousState != ConnectionStatus.connected) { @@ -1213,6 +1215,32 @@ class StreamChatClient { messageId, ); + /// Mark the thread with [threadId] in the channel with [channelId] of type + /// [channelType] as read. + Future markThreadRead( + String channelId, + String channelType, + String threadId, + ) => + _chatApi.channel.markThreadRead( + channelId, + channelType, + threadId, + ); + + /// Mark the thread with [threadId] in the channel with [channelId] of type + /// [channelType] as unread. + Future markThreadUnread( + String channelId, + String channelType, + String threadId, + ) => + _chatApi.channel.markThreadUnread( + channelId, + channelType, + threadId, + ); + /// Creates a new Poll Future createPoll(Poll poll) => _chatApi.polls.createPoll(poll); diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 76b28fb1a..bfe2d8a63 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -337,6 +337,32 @@ class ChannelApi { return EmptyResponse.fromJson(response.data); } + /// Mark the provided [threadId] of the channel as read. + Future markThreadRead( + String channelId, + String channelType, + String threadId, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/read', + data: {'thread_id': threadId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Mark the provided [threadId] of the channel as unread. + Future markThreadUnread( + String channelId, + String channelType, + String threadId, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/unread', + data: {'thread_id': threadId}, + ); + return EmptyResponse.fromJson(response.data); + } + /// Stop watching the channel Future stopWatching( String channelId, diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 1d6ec5e0d..0ca9a007c 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -215,11 +215,17 @@ class PartialUpdateUserRequest extends Equatable { class ThreadOptions extends Equatable { /// {@macro threadOptions} const ThreadOptions({ + this.watch = true, this.replyLimit = 2, this.participantLimit = 100, this.memberLimit = 100, }); + /// If true, the client will watch for changes in the thread. + /// + /// Defaults to true. + final bool watch; + /// The number of most recent replies to return per thread. /// /// Defaults to 2. @@ -239,5 +245,5 @@ class ThreadOptions extends Equatable { Map toJson() => _$ThreadOptionsToJson(this); @override - List get props => [replyLimit, participantLimit, memberLimit]; + List get props => [watch, replyLimit, participantLimit, memberLimit]; } diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index e8846b85d..cffffb6d6 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -45,35 +45,27 @@ PaginationParams _$PaginationParamsFromJson(Map json) => : DateTime.parse(json['created_at_around'] as String), ); -Map _$PaginationParamsToJson(PaginationParams instance) { - final val = { - 'limit': instance.limit, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('offset', instance.offset); - writeNotNull('next', instance.next); - writeNotNull('id_around', instance.idAround); - writeNotNull('id_gt', instance.greaterThan); - writeNotNull('id_gte', instance.greaterThanOrEqual); - writeNotNull('id_lt', instance.lessThan); - writeNotNull('id_lte', instance.lessThanOrEqual); - writeNotNull('created_at_after_or_equal', - instance.createdAtAfterOrEqual?.toIso8601String()); - writeNotNull('created_at_after', instance.createdAtAfter?.toIso8601String()); - writeNotNull('created_at_before_or_equal', - instance.createdAtBeforeOrEqual?.toIso8601String()); - writeNotNull( - 'created_at_before', instance.createdAtBefore?.toIso8601String()); - writeNotNull( - 'created_at_around', instance.createdAtAround?.toIso8601String()); - return val; -} +Map _$PaginationParamsToJson(PaginationParams instance) => + { + 'limit': instance.limit, + if (instance.offset case final value?) 'offset': value, + if (instance.next case final value?) 'next': value, + if (instance.idAround case final value?) 'id_around': value, + if (instance.greaterThan case final value?) 'id_gt': value, + if (instance.greaterThanOrEqual case final value?) 'id_gte': value, + if (instance.lessThan case final value?) 'id_lt': value, + if (instance.lessThanOrEqual case final value?) 'id_lte': value, + if (instance.createdAtAfterOrEqual?.toIso8601String() case final value?) + 'created_at_after_or_equal': value, + if (instance.createdAtAfter?.toIso8601String() case final value?) + 'created_at_after': value, + if (instance.createdAtBeforeOrEqual?.toIso8601String() case final value?) + 'created_at_before_or_equal': value, + if (instance.createdAtBefore?.toIso8601String() case final value?) + 'created_at_before': value, + if (instance.createdAtAround?.toIso8601String() case final value?) + 'created_at_around': value, + }; Map _$PartialUpdateUserRequestToJson( PartialUpdateUserRequest instance) => @@ -90,6 +82,7 @@ Map _$ThreadOptionsToJson(ThreadOptions instance) => { 'stringify': instance.stringify, 'hash_code': instance.hashCode, + 'watch': instance.watch, 'reply_limit': instance.replyLimit, 'participant_limit': instance.participantLimit, 'member_limit': instance.memberLimit, diff --git a/packages/stream_chat/lib/src/core/models/attachment.g.dart b/packages/stream_chat/lib/src/core/models/attachment.g.dart index a9e5fcb01..689f8871e 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -40,38 +40,31 @@ Attachment _$AttachmentFromJson(Map json) => Attachment( : UploadState.fromJson(json['upload_state'] as Map), ); -Map _$AttachmentToJson(Attachment instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('type', instance.type); - writeNotNull('title_link', instance.titleLink); - writeNotNull('title', instance.title); - writeNotNull('thumb_url', instance.thumbUrl); - writeNotNull('text', instance.text); - writeNotNull('pretext', instance.pretext); - writeNotNull('og_scrape_url', instance.ogScrapeUrl); - writeNotNull('image_url', instance.imageUrl); - writeNotNull('footer_icon', instance.footerIcon); - writeNotNull('footer', instance.footer); - writeNotNull('fields', instance.fields); - writeNotNull('fallback', instance.fallback); - writeNotNull('color', instance.color); - writeNotNull('author_name', instance.authorName); - writeNotNull('author_link', instance.authorLink); - writeNotNull('author_icon', instance.authorIcon); - writeNotNull('asset_url', instance.assetUrl); - writeNotNull('actions', instance.actions?.map((e) => e.toJson()).toList()); - writeNotNull('original_width', instance.originalWidth); - writeNotNull('original_height', instance.originalHeight); - writeNotNull('file', instance.file?.toJson()); - val['upload_state'] = instance.uploadState.toJson(); - val['extra_data'] = instance.extraData; - val['id'] = instance.id; - return val; -} +Map _$AttachmentToJson(Attachment instance) => + { + if (instance.type case final value?) 'type': value, + if (instance.titleLink case final value?) 'title_link': value, + if (instance.title case final value?) 'title': value, + if (instance.thumbUrl case final value?) 'thumb_url': value, + if (instance.text case final value?) 'text': value, + if (instance.pretext case final value?) 'pretext': value, + if (instance.ogScrapeUrl case final value?) 'og_scrape_url': value, + if (instance.imageUrl case final value?) 'image_url': value, + if (instance.footerIcon case final value?) 'footer_icon': value, + if (instance.footer case final value?) 'footer': value, + if (instance.fields case final value?) 'fields': value, + if (instance.fallback case final value?) 'fallback': value, + if (instance.color case final value?) 'color': value, + if (instance.authorName case final value?) 'author_name': value, + if (instance.authorLink case final value?) 'author_link': value, + if (instance.authorIcon case final value?) 'author_icon': value, + if (instance.assetUrl case final value?) 'asset_url': value, + if (instance.actions?.map((e) => e.toJson()).toList() case final value?) + 'actions': value, + if (instance.originalWidth case final value?) 'original_width': value, + if (instance.originalHeight case final value?) 'original_height': value, + if (instance.file?.toJson() case final value?) 'file': value, + 'upload_state': instance.uploadState.toJson(), + 'extra_data': instance.extraData, + 'id': instance.id, + }; diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index 00fa1fccb..737cab3db 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/util/serializer.dart'; @@ -22,6 +23,7 @@ class ChannelModel { DateTime? updatedAt, this.deletedAt, this.memberCount = 0, + this.members, Map extraData = const {}, this.team, this.cooldown = 0, @@ -101,6 +103,10 @@ class ChannelModel { @JsonKey(includeToJson: false) final int memberCount; + /// The list of this channel members + @JsonKey(includeToJson: false) + final List? members; + /// The number of seconds in a cooldown @JsonKey(includeIfNull: false) final int cooldown; @@ -143,13 +149,13 @@ class ChannelModel { 'updated_at', 'deleted_at', 'member_count', + 'members', 'team', 'cooldown', ]; /// Shortcut for channel name - String get name => - extraData.containsKey('name') ? extraData['name']! as String : cid; + String? get name => extraData['name'] as String?; /// Serialize to json Map toJson() => Serializer.moveFromExtraDataToRoot( @@ -170,6 +176,7 @@ class ChannelModel { DateTime? updatedAt, DateTime? deletedAt, int? memberCount, + List? members, Map? extraData, String? team, int? cooldown, @@ -190,6 +197,7 @@ class ChannelModel { updatedAt: updatedAt ?? this.updatedAt, deletedAt: deletedAt ?? this.deletedAt, memberCount: memberCount ?? this.memberCount, + members: members ?? this.members, extraData: extraData ?? this.extraData, team: team ?? this.team, cooldown: cooldown ?? this.cooldown, @@ -220,6 +228,7 @@ class ChannelModel { updatedAt: other.updatedAt, deletedAt: other.deletedAt, memberCount: other.memberCount, + members: other.members, extraData: other.extraData, team: other.team, cooldown: other.cooldown, diff --git a/packages/stream_chat/lib/src/core/models/channel_model.g.dart b/packages/stream_chat/lib/src/core/models/channel_model.g.dart index b9f5d03b0..eff3bad62 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.g.dart @@ -33,6 +33,9 @@ ChannelModel _$ChannelModelFromJson(Map json) => ChannelModel( ? null : DateTime.parse(json['deleted_at'] as String), memberCount: (json['member_count'] as num?)?.toInt() ?? 0, + members: (json['members'] as List?) + ?.map((e) => Member.fromJson(e as Map)) + .toList(), extraData: json['extra_data'] as Map? ?? const {}, team: json['team'] as String?, cooldown: (json['cooldown'] as num?)?.toInt() ?? 0, diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 9200578a5..99563e6eb 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -31,6 +31,9 @@ class Event { this.aiState, this.aiMessage, this.messageId, + this.thread, + this.unreadThreadMessages, + this.unreadThreads, this.extraData = const {}, this.isLocal = true, }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); @@ -77,7 +80,7 @@ class Event { final PollVote? pollVote; /// The channel sent with the event - final EventChannel? channel; + final ChannelModel? channel; /// The member sent with the event final Member? member; @@ -115,6 +118,15 @@ class Event { /// The message id to which the event belongs. final String? messageId; + /// The thread object sent with the event. + final Thread? thread; + + /// The number of unread thread messages. + final int? unreadThreadMessages; + + /// The number of unread threads. + final int? unreadThreads; + /// Map of custom channel extraData final Map extraData; @@ -162,6 +174,9 @@ class Event { 'ai_state', 'ai_message', 'message_id', + 'thread', + 'unread_thread_messages', + 'unread_threads', ]; /// Serialize to json @@ -182,7 +197,7 @@ class Event { Message? message, Poll? poll, PollVote? pollVote, - EventChannel? channel, + ChannelModel? channel, Member? member, Reaction? reaction, int? totalUnreadCount, @@ -193,6 +208,9 @@ class Event { AITypingState? aiState, String? aiMessage, String? messageId, + Thread? thread, + int? unreadThreadMessages, + int? unreadThreads, Map? extraData, }) => Event( @@ -215,58 +233,17 @@ class Event { channelType: channelType ?? this.channelType, parentId: parentId ?? this.parentId, hardDelete: hardDelete ?? this.hardDelete, - extraData: extraData ?? this.extraData, aiState: aiState ?? this.aiState, aiMessage: aiMessage ?? this.aiMessage, messageId: messageId ?? this.messageId, + thread: thread ?? this.thread, + unreadThreadMessages: unreadThreadMessages ?? this.unreadThreadMessages, + unreadThreads: unreadThreads ?? this.unreadThreads, isLocal: isLocal, + extraData: extraData ?? this.extraData, ); } -/// The channel embedded in the event object -@JsonSerializable(createToJson: false) -class EventChannel extends ChannelModel { - /// Constructor used for json serialization - EventChannel({ - this.members, - super.id, - super.type, - required String super.cid, - super.ownCapabilities, - required ChannelConfig super.config, - super.createdBy, - super.frozen, - super.lastMessageAt, - required DateTime super.createdAt, - required DateTime super.updatedAt, - super.deletedAt, - super.memberCount, - super.cooldown, - super.team, - super.disabled, - super.hidden, - super.truncatedAt, - Map? extraData, - }) : super(extraData: extraData ?? {}); - - /// Create a new instance from a json - factory EventChannel.fromJson(Map json) => - _$EventChannelFromJson(Serializer.moveToExtraDataFromRoot( - json, - topLevelFields, - )); - - /// A paginated list of channel members - final List? members; - - /// Known top level fields. - /// Useful for [Serializer] methods. - static final topLevelFields = [ - 'members', - ...ChannelModel.topLevelFields, - ]; -} - /// {@template aiState} /// The current typing state of the AI assistant. /// diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart index 0037eaa0d..ee1bf2051 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -36,7 +36,7 @@ Event _$EventFromJson(Map json) => Event( online: json['online'] as bool?, channel: json['channel'] == null ? null - : EventChannel.fromJson(json['channel'] as Map), + : ChannelModel.fromJson(json['channel'] as Map), member: json['member'] == null ? null : Member.fromJson(json['member'] as Map), @@ -48,46 +48,44 @@ Event _$EventFromJson(Map json) => Event( unknownValue: AITypingState.idle), aiMessage: json['ai_message'] as String?, messageId: json['message_id'] as String?, + thread: json['thread'] == null + ? null + : Thread.fromJson(json['thread'] as Map), + unreadThreadMessages: (json['unread_thread_messages'] as num?)?.toInt(), + unreadThreads: (json['unread_threads'] as num?)?.toInt(), extraData: json['extra_data'] as Map? ?? const {}, isLocal: json['is_local'] as bool? ?? false, ); -Map _$EventToJson(Event instance) { - final val = { - 'type': instance.type, - 'cid': instance.cid, - 'channel_id': instance.channelId, - 'channel_type': instance.channelType, - 'connection_id': instance.connectionId, - 'created_at': instance.createdAt.toIso8601String(), - 'me': instance.me?.toJson(), - 'user': instance.user?.toJson(), - 'message': instance.message?.toJson(), - 'poll': instance.poll?.toJson(), - 'poll_vote': instance.pollVote?.toJson(), - 'channel': instance.channel?.toJson(), - 'member': instance.member?.toJson(), - 'reaction': instance.reaction?.toJson(), - 'total_unread_count': instance.totalUnreadCount, - 'unread_channels': instance.unreadChannels, - 'online': instance.online, - 'parent_id': instance.parentId, - 'is_local': instance.isLocal, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('hard_delete', instance.hardDelete); - val['ai_state'] = _$AITypingStateEnumMap[instance.aiState]; - val['ai_message'] = instance.aiMessage; - val['message_id'] = instance.messageId; - val['extra_data'] = instance.extraData; - return val; -} +Map _$EventToJson(Event instance) => { + 'type': instance.type, + 'cid': instance.cid, + 'channel_id': instance.channelId, + 'channel_type': instance.channelType, + 'connection_id': instance.connectionId, + 'created_at': instance.createdAt.toIso8601String(), + 'me': instance.me?.toJson(), + 'user': instance.user?.toJson(), + 'message': instance.message?.toJson(), + 'poll': instance.poll?.toJson(), + 'poll_vote': instance.pollVote?.toJson(), + 'channel': instance.channel?.toJson(), + 'member': instance.member?.toJson(), + 'reaction': instance.reaction?.toJson(), + 'total_unread_count': instance.totalUnreadCount, + 'unread_channels': instance.unreadChannels, + 'online': instance.online, + 'parent_id': instance.parentId, + 'is_local': instance.isLocal, + if (instance.hardDelete case final value?) 'hard_delete': value, + 'ai_state': _$AITypingStateEnumMap[instance.aiState], + 'ai_message': instance.aiMessage, + 'message_id': instance.messageId, + 'thread': instance.thread?.toJson(), + 'unread_thread_messages': instance.unreadThreadMessages, + 'unread_threads': instance.unreadThreads, + 'extra_data': instance.extraData, + }; const _$AITypingStateEnumMap = { AITypingState.idle: 'AI_STATE_IDLE', @@ -96,32 +94,3 @@ const _$AITypingStateEnumMap = { AITypingState.thinking: 'AI_STATE_THINKING', AITypingState.generating: 'AI_STATE_GENERATING', }; - -EventChannel _$EventChannelFromJson(Map json) => EventChannel( - members: (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList(), - id: json['id'] as String?, - type: json['type'] as String?, - cid: json['cid'] as String, - ownCapabilities: (json['own_capabilities'] as List?) - ?.map((e) => e as String) - .toList(), - config: ChannelConfig.fromJson(json['config'] as Map), - createdBy: json['created_by'] == null - ? null - : User.fromJson(json['created_by'] as Map), - frozen: json['frozen'] as bool? ?? false, - lastMessageAt: json['last_message_at'] == null - ? null - : DateTime.parse(json['last_message_at'] as String), - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), - deletedAt: json['deleted_at'] == null - ? null - : DateTime.parse(json['deleted_at'] as String), - memberCount: (json['member_count'] as num?)?.toInt() ?? 0, - cooldown: (json['cooldown'] as num?)?.toInt() ?? 0, - team: json['team'] as String?, - extraData: json['extra_data'] as Map?, - ); diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart index 790d5c019..efb7d0dd3 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -78,28 +78,18 @@ Message _$MessageFromJson(Map json) => Message( ), ); -Map _$MessageToJson(Message instance) { - final val = { - 'id': instance.id, - 'text': instance.text, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('type', Message._typeToJson(instance.type)); - val['attachments'] = instance.attachments.map((e) => e.toJson()).toList(); - val['mentioned_users'] = User.toIds(instance.mentionedUsers); - val['parent_id'] = instance.parentId; - val['quoted_message_id'] = instance.quotedMessageId; - val['show_in_channel'] = instance.showInChannel; - val['silent'] = instance.silent; - val['pinned'] = instance.pinned; - val['pin_expires'] = instance.pinExpires?.toIso8601String(); - val['poll_id'] = instance.pollId; - val['extra_data'] = instance.extraData; - return val; -} +Map _$MessageToJson(Message instance) => { + 'id': instance.id, + 'text': instance.text, + if (Message._typeToJson(instance.type) case final value?) 'type': value, + 'attachments': instance.attachments.map((e) => e.toJson()).toList(), + 'mentioned_users': User.toIds(instance.mentionedUsers), + 'parent_id': instance.parentId, + 'quoted_message_id': instance.quotedMessageId, + 'show_in_channel': instance.showInChannel, + 'silent': instance.silent, + 'pinned': instance.pinned, + 'pin_expires': instance.pinExpires?.toIso8601String(), + 'poll_id': instance.pollId, + 'extra_data': instance.extraData, + }; diff --git a/packages/stream_chat/lib/src/core/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart index cfa7f79a5..9f6f8f5be 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -16,6 +16,7 @@ class OwnUser extends User { this.totalUnreadCount = 0, this.unreadChannels = 0, this.channelMutes = const [], + this.unreadThreads = 0, required super.id, super.role, super.name, @@ -73,6 +74,7 @@ class OwnUser extends User { List? mutes, int? totalUnreadCount, int? unreadChannels, + int? unreadThreads, String? language, }) => OwnUser( @@ -96,6 +98,7 @@ class OwnUser extends User { mutes: mutes ?? this.mutes, totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, unreadChannels: unreadChannels ?? this.unreadChannels, + unreadThreads: unreadThreads ?? this.unreadThreads, language: language ?? this.language, ); @@ -120,6 +123,7 @@ class OwnUser extends User { teams: other.teams, totalUnreadCount: other.totalUnreadCount, unreadChannels: other.unreadChannels, + unreadThreads: other.unreadThreads, updatedAt: other.updatedAt, language: other.language, ); @@ -145,6 +149,10 @@ class OwnUser extends User { @JsonKey(includeIfNull: false) final int unreadChannels; + /// Total unread threads by the user. + @JsonKey(includeIfNull: false) + final int unreadThreads; + /// Known top level fields. /// /// Useful for [Serializer] methods. @@ -154,6 +162,7 @@ class OwnUser extends User { 'total_unread_count', 'unread_channels', 'channel_mutes', + 'unread_threads', ...User.topLevelFields, ]; } diff --git a/packages/stream_chat/lib/src/core/models/own_user.g.dart b/packages/stream_chat/lib/src/core/models/own_user.g.dart index 29aecb8c1..b496ade00 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.g.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.g.dart @@ -21,6 +21,7 @@ OwnUser _$OwnUserFromJson(Map json) => OwnUser( ?.map((e) => ChannelMute.fromJson(e as Map)) .toList() ?? const [], + unreadThreads: (json['unread_threads'] as num?)?.toInt() ?? 0, id: json['id'] as String, role: json['role'] as String?, createdAt: json['created_at'] == null diff --git a/packages/stream_chat/lib/src/core/models/poll_option.g.dart b/packages/stream_chat/lib/src/core/models/poll_option.g.dart index 623d5e4fc..b7db99711 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.g.dart @@ -12,17 +12,9 @@ PollOption _$PollOptionFromJson(Map json) => PollOption( extraData: json['extra_data'] as Map? ?? const {}, ); -Map _$PollOptionToJson(PollOption instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('id', instance.id); - val['text'] = instance.text; - val['extra_data'] = instance.extraData; - return val; -} +Map _$PollOptionToJson(PollOption instance) => + { + if (instance.id case final value?) 'id': value, + 'text': instance.text, + 'extra_data': instance.extraData, + }; diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart index 37f8aec79..1ceb22c1d 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart @@ -23,17 +23,8 @@ PollVote _$PollVoteFromJson(Map json) => PollVote( : User.fromJson(json['user'] as Map), ); -Map _$PollVoteToJson(PollVote instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('id', instance.id); - writeNotNull('option_id', instance.optionId); - writeNotNull('answer_text', instance.answerText); - return val; -} +Map _$PollVoteToJson(PollVote instance) => { + if (instance.id case final value?) 'id': value, + if (instance.optionId case final value?) 'option_id': value, + if (instance.answerText case final value?) 'answer_text': value, + }; diff --git a/packages/stream_chat/lib/src/core/models/thread.dart b/packages/stream_chat/lib/src/core/models/thread.dart index 62c097b39..15728d677 100644 --- a/packages/stream_chat/lib/src/core/models/thread.dart +++ b/packages/stream_chat/lib/src/core/models/thread.dart @@ -17,6 +17,7 @@ part 'thread.g.dart'; class Thread extends Equatable { /// {@macro streamThread} Thread({ + this.activeParticipantCount, this.channel, required this.channelCid, required this.parentMessageId, @@ -41,6 +42,9 @@ class Thread extends Equatable { factory Thread.fromJson(Map json) => _$ThreadFromJson( Serializer.moveToExtraDataFromRoot(json, topLevelFields)); + /// The active participant count in the thread. + final int? activeParticipantCount; + /// The channel cid this thread belongs to. final String channelCid; @@ -77,7 +81,7 @@ class Thread extends Equatable { /// The number of users participating in the thread. final int participantCount; - /// The list of users participating in the thread. + /// The list of participants in the thread. final List threadParticipants; /// The date of the last message in the thread. @@ -98,6 +102,7 @@ class Thread extends Equatable { /// Creates a copy of [Thread] with specified attributes overridden. Thread copyWith({ + int? activeParticipantCount, ChannelModel? channel, String? channelCid, DateTime? createdAt, @@ -117,6 +122,8 @@ class Thread extends Equatable { Map? extraData, }) => Thread( + activeParticipantCount: + activeParticipantCount ?? this.activeParticipantCount, channel: channel ?? this.channel, channelCid: channelCid ?? this.channelCid, createdAt: createdAt ?? this.createdAt, @@ -136,10 +143,36 @@ class Thread extends Equatable { extraData: extraData ?? this.extraData, ); + /// Merge this thread with the [other] thread. + Thread merge(Thread? other) { + if (other == null) return this; + return copyWith( + activeParticipantCount: other.activeParticipantCount, + channel: other.channel, + channelCid: other.channelCid, + createdAt: other.createdAt, + updatedAt: other.updatedAt, + deletedAt: other.deletedAt, + createdByUserId: other.createdByUserId, + createdBy: other.createdBy, + title: other.title, + parentMessageId: other.parentMessageId, + parentMessage: other.parentMessage, + replyCount: other.replyCount, + participantCount: other.participantCount, + threadParticipants: other.threadParticipants, + lastMessageAt: other.lastMessageAt, + latestReplies: other.latestReplies, + read: other.read, + extraData: other.extraData, + ); + } + /// Known top level fields. /// /// Useful for [Serializer] methods. static const topLevelFields = [ + 'active_participant_count', 'channel_cid', 'channel', 'created_at', @@ -160,6 +193,7 @@ class Thread extends Equatable { @override List get props => [ + activeParticipantCount, channelCid, channel, createdAt, diff --git a/packages/stream_chat/lib/src/core/models/thread.g.dart b/packages/stream_chat/lib/src/core/models/thread.g.dart index 2ecef8aa8..0fc3ab6d1 100644 --- a/packages/stream_chat/lib/src/core/models/thread.g.dart +++ b/packages/stream_chat/lib/src/core/models/thread.g.dart @@ -7,6 +7,8 @@ part of 'thread.dart'; // ************************************************************************** Thread _$ThreadFromJson(Map json) => Thread( + activeParticipantCount: + (json['active_participant_count'] as num?)?.toInt(), channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), @@ -51,6 +53,7 @@ Thread _$ThreadFromJson(Map json) => Thread( ); Map _$ThreadToJson(Thread instance) => { + 'active_participant_count': instance.activeParticipantCount, 'channel_cid': instance.channelCid, 'channel': instance.channel?.toJson(), 'created_at': instance.createdAt.toIso8601String(), diff --git a/packages/stream_chat/lib/src/core/models/thread_participant.g.dart b/packages/stream_chat/lib/src/core/models/thread_participant.g.dart new file mode 100644 index 000000000..4a410c404 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/thread_participant.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'thread_participant.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ThreadParticipant _$ThreadParticipantFromJson(Map json) => + ThreadParticipant( + channelCid: json['channel_cid'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + lastReadAt: DateTime.parse(json['last_read_at'] as String), + lastThreadMessageAt: json['last_thread_message_at'] == null + ? null + : DateTime.parse(json['last_thread_message_at'] as String), + leftThreadAt: json['left_thread_at'] == null + ? null + : DateTime.parse(json['left_thread_at'] as String), + threadId: json['thread_id'] as String?, + userId: json['user_id'] as String?, + user: json['user'] == null + ? null + : User.fromJson(json['user'] as Map), + ); + +Map _$ThreadParticipantToJson(ThreadParticipant instance) => + { + 'channel_cid': instance.channelCid, + 'created_at': instance.createdAt.toIso8601String(), + 'last_read_at': instance.lastReadAt.toIso8601String(), + 'last_thread_message_at': instance.lastThreadMessageAt?.toIso8601String(), + 'left_thread_at': instance.leftThreadAt?.toIso8601String(), + 'thread_id': instance.threadId, + 'user_id': instance.userId, + 'user': instance.user?.toJson(), + }; diff --git a/packages/stream_chat/lib/src/core/models/user.g.dart b/packages/stream_chat/lib/src/core/models/user.g.dart index 47094ffc2..68c227d43 100644 --- a/packages/stream_chat/lib/src/core/models/user.g.dart +++ b/packages/stream_chat/lib/src/core/models/user.g.dart @@ -30,18 +30,8 @@ User _$UserFromJson(Map json) => User( language: json['language'] as String?, ); -Map _$UserToJson(User instance) { - final val = { - 'id': instance.id, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('language', instance.language); - val['extra_data'] = instance.extraData; - return val; -} +Map _$UserToJson(User instance) => { + 'id': instance.id, + if (instance.language case final value?) 'language': value, + 'extra_data': instance.extraData, + }; diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index 4b6e84254..9c3ee8226 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -145,4 +145,11 @@ class EventType { /// Event sent when a poll is deleted. static const String pollDeleted = 'poll.deleted'; + + /// Event sent when a thread is updated. + static const String threadUpdated = 'thread.updated'; + + /// Event sent when a new message is added to a thread. + static const String notificationThreadMessageNew = + 'notification.thread_message_new'; } diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 41ea00d91..622787281 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -48,6 +48,8 @@ export 'src/core/models/poll_vote.dart'; export 'src/core/models/poll_voting_mode.dart'; export 'src/core/models/reaction.dart'; export 'src/core/models/read.dart'; +export 'src/core/models/thread.dart'; +export 'src/core/models/thread_participant.dart'; export 'src/core/models/user.dart'; export 'src/core/platform_detector/platform_detector.dart'; export 'src/core/util/extension.dart'; diff --git a/packages/stream_chat/test/fixtures/event.json b/packages/stream_chat/test/fixtures/event.json index 040edf62d..cc567a9b8 100644 --- a/packages/stream_chat/test/fixtures/event.json +++ b/packages/stream_chat/test/fixtures/event.json @@ -27,5 +27,7 @@ "name": "Dry meadow" }, "ai_state": "AI_STATE_THINKING", - "ai_message": "Some message" + "ai_message": "Some message", + "unread_thread_messages": 2, + "unread_threads": 3 } \ No newline at end of file diff --git a/packages/stream_chat/test/src/core/models/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart index 3a265f4a5..2293bbad9 100644 --- a/packages/stream_chat/test/src/core/models/event_test.dart +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -16,6 +16,8 @@ void main() { expect(event.isLocal, false); expect(event.aiState, AITypingState.thinking); expect(event.aiMessage, 'Some message'); + expect(event.unreadThreadMessages, 2); + expect(event.unreadThreads, 3); }); test('should serialize to json correctly', () { @@ -32,6 +34,8 @@ void main() { aiState: AITypingState.thinking, aiMessage: 'Some message', messageId: 'messageId', + unreadThreadMessages: 2, + unreadThreads: 3, ); expect( @@ -59,6 +63,9 @@ void main() { 'ai_state': 'AI_STATE_THINKING', 'ai_message': 'Some message', 'message_id': 'messageId', + 'thread': null, + 'unread_thread_messages': 2, + 'unread_threads': 3, }, ); }); @@ -73,6 +80,8 @@ void main() { expect(newEvent.me, isA()); expect(newEvent.user, isA()); expect(newEvent.isLocal, false); + expect(newEvent.unreadThreadMessages, 2); + expect(newEvent.unreadThreads, 3); newEvent = event.copyWith( type: 'test', @@ -83,6 +92,8 @@ void main() { channelId: 'test', totalUnreadCount: 2, channelType: 'testtype', + unreadThreadMessages: 6, + unreadThreads: 7, ); expect(newEvent.channelType, 'testtype'); @@ -93,22 +104,8 @@ void main() { expect(newEvent.connectionId, 'test'); expect(newEvent.extraData, {}); expect(newEvent.user!.id, 'test'); - }); - - group('eventChannel', () { - test('should parse json correctly', () { - final eventChannel = - EventChannel.fromJson(jsonFixture('event_channel.json')); - expect(eventChannel.type, 'messaging'); - expect(eventChannel.cid, - 'messaging:!members-v9ktpgmYysZA-MjgC-GMoeEawFHSelkOdTu6JGxFZWU'); - expect(eventChannel.createdBy!.id, 'super-band-9'); - expect(eventChannel.frozen, false); - expect(eventChannel.members!.length, 2); - expect(eventChannel.memberCount, 2); - expect(eventChannel.config, isA()); - expect(eventChannel.name, 'test'); - }); + expect(newEvent.unreadThreadMessages, 6); + expect(newEvent.unreadThreads, 7); }); }); } diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index dd3231302..15880b2d6 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -16,7 +16,7 @@ Future main() async { /// Create a new instance of [StreamChatClient] passing the apikey obtained /// from your project dashboard. final client = StreamChatClient( - 's2dxdhpxd94g', + 'zcgvnykxsfm8', logLevel: Level.OFF, ); @@ -26,10 +26,8 @@ Future main() async { /// /// Please see the following for more information: /// https://getstream.io/chat/docs/ios_user_setup_and_tokens/ - await client.connectUser( - User(id: 'super-band-9'), - '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A''', - ); + await client.connectUser(User(id: 'luke'), + '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIifQ.b6EiC8dq2AHk0JPfI-6PN-AM9TVzt8JV-qB1N9kchlI'''); runApp( MyApp(client: client), @@ -65,7 +63,7 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: ThemeData.light(), darkTheme: ThemeData.dark(), - // themeMode: ThemeMode.dark, + themeMode: ThemeMode.light, supportedLocales: const [ Locale('en'), Locale('hi'), @@ -78,7 +76,70 @@ class MyApp extends StatelessWidget { client: client, child: widget, ), - home: const ResponsiveChat(), + home: const ThreadListPage(), + ); + } +} + +class ThreadListPage extends StatefulWidget { + const ThreadListPage({super.key}); + + @override + State createState() => _ThreadListPageState(); +} + +class _ThreadListPageState extends State { + late final controller = StreamThreadListController( + client: StreamChat.of(context).client, + ); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Threads')), + body: Column( + children: [ + ValueListenableBuilder( + valueListenable: controller.unseenThreadIds, + builder: (_, unreadThreads, __) => UnreadThreadsBanner( + unreadThreads: unreadThreads, + onTap: () => controller + .refresh(resetValue: false) + .then((_) => controller.clearUnseenThreadIds()), + ), + ), + Expanded( + child: StreamThreadListView( + controller: controller, + onThreadTap: (thread) async { + final channelCid = thread.channelCid; + + final channel = StreamChat.of(context).client.channel( + channelCid.split(':')[0], + id: channelCid.split(':')[1], + ); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return StreamChannel( + channel: channel, + child: ThreadPage(parent: thread.parentMessage!), + ); + }, + ), + ); + }, + ), + ), + ], + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index 4ff54c187..cb1d80f70 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -34,26 +34,29 @@ class StreamMessagePreviewText extends StatelessWidget { caseSensitive: false, ); - final messageTextParts = [ - ...messageAttachments.map((it) { - if (it.type == AttachmentType.image) { - return '📷'; - } else if (it.type == AttachmentType.video) { - return '🎬'; - } else if (it.type == AttachmentType.giphy) { - return '[GIF]'; - } - return it == message.attachments.last - ? (it.title ?? 'File') - : '${it.title ?? 'File'} , '; - }), - if (message.poll?.name case final pollName?) '📊 $pollName', - if (messageText != null) - if (messageMentionedUsers.isNotEmpty) - ...mentionedUsersRegex.allMatchesWithSep(messageText) - else - messageText, - ]; + final messageTextParts = switch (message.isDeleted) { + // Show the deleted message label if the message is deleted. + true => [context.translations.messageDeletedLabel], + // Otherwise, combine the message text with the attachments and poll. + false => [ + ...messageAttachments.map( + (it) => switch (it.type) { + AttachmentType.image => '📷', + AttachmentType.video => '🎬', + AttachmentType.giphy => '[GIF]', + _ => it == message.attachments.last + ? (it.title ?? 'File') + : '${it.title ?? 'File'} , ', + }, + ), + if (message.poll?.name case final pollName?) '📊 $pollName', + if (messageText != null) + if (messageMentionedUsers.isNotEmpty) + ...mentionedUsersRegex.allMatchesWithSep(messageText) + else + messageText, + ] + }; final fontStyle = (message.isSystem || message.isDeleted) ? FontStyle.italic @@ -67,28 +70,28 @@ class StreamMessagePreviewText extends StatelessWidget { ); final spans = [ - for (final part in messageTextParts) - if (messageMentionedUsers.isNotEmpty && - messageMentionedUsers.any((it) => '@${it.name}' == part)) - TextSpan( + ...messageTextParts.map((part) { + if (messageMentionedUsers.any((it) => '@${it.name}' == part)) { + return TextSpan( text: part, style: mentionsTextStyle, - ) - else if (messageAttachments.isNotEmpty && - messageAttachments - .where((it) => it.title != null) - .any((it) => it.title == part)) - TextSpan( + ); + } + + if (messageAttachments.any((it) => it.title == part)) { + return TextSpan( text: part, style: regularTextStyle?.copyWith( fontStyle: FontStyle.italic, ), - ) - else - TextSpan( - text: part, - style: regularTextStyle, - ), + ); + } + + return TextSpan( + text: part, + style: regularTextStyle, + ); + }) ]; return Text.rich( diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index dcf3ff38f..23ebb29ce 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -481,6 +481,12 @@ abstract class Translations { /// The label for "Error loading poll votes". String get loadingPollVotesError; + + /// The label for "replied to:" + String get repliedToLabel; + + /// The label for "$count new threads" + String newThreadsLabel({required int count}); } /// Default implementation of Translation strings for the stream chat widgets @@ -1085,4 +1091,13 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get loadingPollVotesError => 'Error loading poll votes'; + + @override + String get repliedToLabel => 'replied to:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 new thread'; + return '$count new threads'; + } } diff --git a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart index 93e517c19..e6453e9f9 100644 --- a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart @@ -1132,6 +1132,19 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + factory StreamSvgIcon.reload({ + double? size, + Color? color, + }) { + return StreamSvgIcon( + assetName: 'reload.svg', + color: color, + width: size, + height: size, + ); + } + /// Name of icon asset final String? assetName; diff --git a/packages/stream_chat_flutter/lib/src/misc/timestamp.dart b/packages/stream_chat_flutter/lib/src/misc/timestamp.dart new file mode 100644 index 000000000..e0b75f1f3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/timestamp.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; + +/// {@template streamTimestamp} +/// Represents a timestamp, that's used primarily for showing the time of a +/// message. +/// +/// This widget uses the [formatDate] function to format the date to a String. +/// {@endtemplate} +class StreamTimestamp extends StatelessWidget { + /// {@macro streamTimestamp} + const StreamTimestamp({ + super.key, + required this.date, + this.formatter = formatDate, + this.style, + this.textAlign, + this.textDirection, + }); + + /// The date to show in the timestamp. + final DateTime date; + + /// The formatter that's used to format the date to a String. + final DateFormatter formatter; + + /// The style to apply to the text. + final TextStyle? style; + + /// The alignment of the text. + final TextAlign? textAlign; + + /// The direction of the text. + final TextDirection? textDirection; + + @override + Widget build(BuildContext context) { + return Text( + formatter(context, date), + maxLines: 1, + style: style, + textAlign: textAlign, + textDirection: textDirection, + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart new file mode 100644 index 000000000..0f27c70fe --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -0,0 +1,258 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/timestamp.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamThreadListTile} +/// A widget that displays a thread in a list. +/// +/// This widget is used in the [ThreadListView] to display a thread. +/// +/// The widget displays the channel name, the message the thread is replying to, +/// the latest reply, and the unread message count. +/// {@endtemplate} +class StreamThreadListTile extends StatelessWidget { + /// {@macro streamThreadListTile} + const StreamThreadListTile({ + super.key, + required this.thread, + this.currentUser, + this.onTap, + this.onLongPress, + }); + + /// The thread to display. + final Thread thread; + + /// The current user. + final User? currentUser; + + /// Called when the user taps this list tile. + final GestureTapCallback? onTap; + + /// Called when the user long-presses on this list tile. + final GestureLongPressCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final language = currentUser?.language; + final unreadMessageCount = thread.read + ?.firstWhereOrNull((read) => read.user.id == currentUser?.id) + ?.unreadMessages; + + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (thread.channel case final channel?) + ThreadTitle( + channelName: channel.formatName(currentUser: currentUser), + ), + Row( + children: [ + if (thread.parentMessage case final parentMessage?) + Expanded( + child: ThreadReplyToContent( + language: language, + prefix: context.translations.repliedToLabel, + parentMessage: parentMessage, + ), + ), + if (unreadMessageCount case final count? when count > 0) + ThreadUnreadCount(unreadCount: count), + ], + ), + if (thread.latestReplies.lastOrNull case final latestReply?) + ThreadLatestReply( + language: language, + latestReply: latestReply, + ), + ].insertBetween(const SizedBox(height: 6)), + ), + ), + ); + } +} + +/// {@template threadTitle} +/// A widget that displays the channel name. +/// {@endtemplate} +class ThreadTitle extends StatelessWidget { + /// {@macro threadTitle} + const ThreadTitle({ + super.key, + this.channelName, + }); + + /// The channel name to display. + final String? channelName; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.message_outlined, + size: 16, + color: theme.colorTheme.textHighEmphasis, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + channelName ?? context.translations.noTitleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyBold, + ), + ), + ], + ); + } +} + +/// {@template threadReplyToContent} +/// A widget that displays the message the thread is replying to. +/// {@endtemplate} +class ThreadReplyToContent extends StatelessWidget { + /// {@macro threadReplyToContent} + const ThreadReplyToContent({ + super.key, + this.language, + this.prefix = 'replied to:', + required this.parentMessage, + }); + + /// The prefix to display before the message. + /// + /// Defaults to `replied to:`. + final String prefix; + + /// The language of the message. + final String? language; + + /// The message the thread is replying to. + final Message parentMessage; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + prefix, + style: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + const SizedBox(width: 4), + Flexible( + child: StreamMessagePreviewText( + language: language, + message: parentMessage, + textStyle: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + ), + ], + ); + } +} + +/// {@template threadUnreadCount} +/// A widget that displays the unread message count. +/// {@endtemplate} +class ThreadUnreadCount extends StatelessWidget { + /// {@macro threadUnreadCount} + const ThreadUnreadCount({ + super.key, + required this.unreadCount, + }) : assert(unreadCount > 0, 'unreadCount must be greater than 0'); + + /// The number of unread messages. + final int unreadCount; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Badge( + textColor: Colors.white, + textStyle: theme.textTheme.footnoteBold, + backgroundColor: theme.channelPreviewTheme.unreadCounterColor, + label: Text('$unreadCount'), + ); + } +} + +/// {@template threadLatestReply} +/// A widget that displays the latest reply in the thread. +/// {@endtemplate} +class ThreadLatestReply extends StatelessWidget { + /// {@macro threadLatestReply} + const ThreadLatestReply({ + super.key, + this.language, + required this.latestReply, + }); + + /// The language of the message. + final String? language; + + /// The latest reply in the thread. + final Message latestReply; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Row( + children: [ + if (latestReply.user case final user?) StreamUserAvatar(user: user), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + latestReply.user!.name, + style: theme.textTheme.bodyBold, + ), + Row( + children: [ + Expanded( + child: StreamMessagePreviewText( + language: language, + message: latestReply, + textStyle: theme.textTheme.body.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + ), + StreamTimestamp( + date: latestReply.createdAt.toLocal(), + style: theme.textTheme.body.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + ], + ), + ], + ), + ), + ].insertBetween(const SizedBox(width: 8)), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart new file mode 100644 index 000000000..39344f9f5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart @@ -0,0 +1,376 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; +import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Default separator builder for [StreamThreadListView]. +Widget defaultThreadListViewSeparatorBuilder( + BuildContext context, + List threads, + int index, +) => + const StreamThreadListSeparator(); + +/// Signature for the item builder that creates the children of the +/// [StreamThreadListView]. +typedef StreamThreadListViewIndexedWidgetBuilder + = StreamScrollViewIndexedWidgetBuilder; + +/// {@template streamThreadListView} +/// A [ListView] that shows a list of [Thread]'s. It uses a +/// [StreamThreadListController] to load the threads in paginated form. +/// +/// Example: +/// +/// ```dart +/// StreamThreadListView( +/// controller: controller, +/// onThreadTap: (thread) { +/// // Handle thread tap event +/// }, +/// onThreadLongPress: (thread) { +/// // Handle thread long press event +/// }, +/// ) +/// ``` +/// +/// See also: +/// * [StreamThreadListTile] +/// * [StreamThreadListController] +/// {@endtemplate} +class StreamThreadListView extends StatelessWidget { + /// {@macro streamThreadListView} + const StreamThreadListView({ + super.key, + required this.controller, + this.itemBuilder, + this.separatorBuilder = defaultThreadListViewSeparatorBuilder, + this.emptyBuilder, + this.loadingBuilder, + this.errorBuilder, + this.onThreadTap, + this.onThreadLongPress, + this.loadMoreTriggerIndex = 3, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.scrollController, + this.primary, + this.physics, + this.shrinkWrap = false, + this.padding, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + }); + + /// The [StreamThreadListController] used to control the threads in the list. + final StreamThreadListController controller; + + /// A builder that is called to build items in the [ListView]. + final StreamThreadListViewIndexedWidgetBuilder? itemBuilder; + + /// A builder that is called to build the list separator. + final PagedValueScrollViewIndexedWidgetBuilder separatorBuilder; + + /// A builder that is called to build the empty state of the list. + final WidgetBuilder? emptyBuilder; + + /// A builder that is called to build the loading state of the list. + final WidgetBuilder? loadingBuilder; + + /// A builder that is called to build the error state of the list. + final Widget Function(BuildContext, StreamChatError)? errorBuilder; + + /// Called when the user taps this list tile. + final void Function(Thread)? onThreadTap; + + /// Called when the user long-presses on this list tile. + final void Function(Thread)? onThreadLongPress; + + /// The index to take into account when triggering [controller.loadMore]. + final int loadMoreTriggerIndex; + + /// {@template flutter.widgets.scroll_view.scrollDirection} + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + /// {@endtemplate} + final Axis scrollDirection; + + /// The amount of space by which to inset the children. + final EdgeInsetsGeometry? padding; + + /// Whether to wrap each child in an [AutomaticKeepAlive]. + /// + /// Typically, children in lazy list are wrapped in [AutomaticKeepAlive] + /// widgets so that children can use [KeepAliveNotification]s to preserve + /// their state when they would otherwise be garbage collected off-screen. + /// + /// This feature (and [addRepaintBoundaries]) must be disabled if the children + /// are going to manually maintain their [KeepAlive] state. It may also be + /// more efficient to disable this feature if it is known ahead of time that + /// none of the children will ever try to keep themselves alive. + /// + /// Defaults to true. + final bool addAutomaticKeepAlives; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// Typically, children in a scrolling container are wrapped in repaint + /// boundaries so that they do not need to be repainted as the list scrolls. + /// If the children are easy to repaint (e.g., solid color blocks or a short + /// snippet of text), it might be more efficient to not add a repaint boundary + /// and simply repaint the children during scrolling. + /// + /// Defaults to true. + final bool addRepaintBoundaries; + + /// Whether to wrap each child in an [IndexedSemantics]. + /// + /// Typically, children in a scrolling container must be annotated with a + /// semantic index in order to generate the correct accessibility + /// announcements. This should only be set to false if the indexes have + /// already been provided by an [IndexedSemantics] widget. + /// + /// Defaults to true. + /// + /// See also: + /// + /// * [IndexedSemantics], for an explanation of how to manually + /// provide semantic indexes. + final bool addSemanticIndexes; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// For example, if [scrollDirection] is [Axis.vertical], then the scroll view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// {@template flutter.widgets.scroll_view.controller} + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// Must be null if [primary] is true. + /// + /// A [ScrollController] serves several purposes. It can be used to control + /// the initial scroll position (see [ScrollController.initialScrollOffset]). + /// It can be used to control whether the scroll view should automatically + /// save and restore its scroll position in the [PageStorage] (see + /// [ScrollController.keepScrollOffset]). It can be used to read the current + /// scroll position (see [ScrollController.offset]), or change it (see + /// [ScrollController.animateTo]). + /// {@endtemplate} + final ScrollController? scrollController; + + /// {@template flutter.widgets.scroll_view.primary} + /// Whether this is the primary scroll view associated with the parent + /// [PrimaryScrollController]. + /// + /// When this is true, the scroll view is scrollable even if it does not have + /// sufficient content to actually scroll. Otherwise, by default the user can + /// only scroll the view if it has sufficient content. See [physics]. + /// + /// Also when true, the scroll view is used for default [ScrollAction]s. If a + /// ScrollAction is not handled by an otherwise focused part of the + /// application, the ScrollAction will be evaluated using this scroll view, + /// for example, when executing [Shortcuts] key events like page up and down. + /// + /// On iOS, this also identifies the scroll view that will scroll to top in + /// response to a tap in the status bar. + /// {@endtemplate} + /// + /// Defaults to true when [scrollController] is null. + final bool? primary; + + /// {@template flutter.widgets.scroll_view.shrinkWrap} + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// If the scroll view does not shrink wrap, then the scroll view will expand + /// to the maximum allowed size in the [scrollDirection]. If the scroll view + /// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must + /// be true. + /// + /// Shrink wrapping the content of the scroll view is significantly more + /// expensive than expanding to the maximum allowed size because the content + /// can expand and contract during scrolling, which means the size of the + /// scroll view needs to be recomputed whenever the scroll position changes. + /// + /// Defaults to false. + /// {@endtemplate} + final bool shrinkWrap; + + /// {@template flutter.widgets.scroll_view.physics} + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. Furthermore, if [primary] is + /// false, then the user cannot scroll if there is insufficient content to + /// scroll, while if [primary] is true, they can always attempt to scroll. + /// + /// To force the scroll view to always be scrollable even if there is + /// insufficient content, as if [primary] was true but without necessarily + /// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics + /// object, as in: + /// + /// ```dart + /// physics: const AlwaysScrollableScrollPhysics(), + /// ``` + /// + /// To force the scroll view to use the default platform conventions and not + /// be scrollable if there is insufficient content, regardless of the value of + /// [primary], provide an explicit [ScrollPhysics] object, as in: + /// + /// ```dart + /// physics: const ScrollPhysics(), + /// ``` + /// + /// The physics can be changed dynamically (by providing a new object in a + /// subsequent build), but new physics will only take effect if the _class_ of + /// the provided object changes. Merely constructing a new instance with a + /// different configuration is insufficient to cause the physics to be + /// reapplied. (This is because the final object used is generated + /// dynamically, which can be relatively expensive, and it would be + /// inefficient to speculatively create this object each frame to see if the + /// physics should be updated.) + /// {@endtemplate} + /// + /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the + /// [ScrollPhysics] provided by that behavior will take precedence after + /// [physics]. + final ScrollPhysics? physics; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + final double? cacheExtent; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + /// {@endtemplate} + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + return PagedValueListView( + scrollDirection: scrollDirection, + padding: padding, + physics: physics, + reverse: reverse, + controller: controller, + scrollController: scrollController, + primary: primary, + shrinkWrap: shrinkWrap, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + keyboardDismissBehavior: keyboardDismissBehavior, + restorationId: restorationId, + dragStartBehavior: dragStartBehavior, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + loadMoreTriggerIndex: loadMoreTriggerIndex, + separatorBuilder: separatorBuilder, + itemBuilder: (context, threads, index) { + final thread = threads[index]; + final currentUser = StreamChat.of(context).currentUser; + final onTap = onThreadTap; + final onLongPress = onThreadLongPress; + + final tile = StreamThreadListTile( + thread: thread, + currentUser: currentUser, + onTap: onTap == null ? null : () => onTap(thread), + onLongPress: onLongPress == null ? null : () => onLongPress(thread), + ); + + return itemBuilder?.call(context, threads, index, tile) ?? tile; + }, + emptyBuilder: (context) { + final chatThemeData = StreamChatTheme.of(context); + return emptyBuilder?.call(context) ?? + Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: StreamScrollViewEmptyWidget( + emptyIcon: StreamSvgIcon.thread( + size: 148, + color: chatThemeData.colorTheme.disabled, + ), + emptyTitle: Text( + context.translations.emptyMessagesText, + style: chatThemeData.textTheme.headline, + ), + ), + ), + ); + }, + loadMoreErrorBuilder: (context, error) => + StreamScrollViewLoadMoreError.list( + onTap: controller.retry, + error: Text(context.translations.loadingMessagesError), + ), + loadMoreIndicatorBuilder: (context) => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: StreamScrollViewLoadMoreIndicator(), + ), + ), + loadingBuilder: (context) => + loadingBuilder?.call(context) ?? + const Center( + child: StreamScrollViewLoadingWidget(), + ), + errorBuilder: (context, error) => + errorBuilder?.call(context, error) ?? + Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(context.translations.loadingMessagesError), + onRetryPressed: controller.refresh, + ), + ), + ); + } +} + +/// A widget that is used to display a separator between +/// [StreamThreadListTile] items. +class StreamThreadListSeparator extends StatelessWidget { + /// Creates a new instance of [StreamThreadListSeparator]. + const StreamThreadListSeparator({super.key}); + + @override + Widget build(BuildContext context) { + final effect = StreamChatTheme.of(context).colorTheme.borderBottom; + return Container( + height: 1, + // ignore: deprecated_member_use + color: effect.color!.withOpacity(effect.alpha ?? 1.0), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/unread_threads_banner.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/unread_threads_banner.dart new file mode 100644 index 000000000..f4737a3b9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/unread_threads_banner.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; + +/// {@template unreadThreadsBanner} +/// A widget that shows a banner with the number of unread threads. +/// +/// This widget can be used to show a banner with the number of unread threads +/// on the top of the [ThreadListView]. +/// {@endtemplate} +class UnreadThreadsBanner extends StatelessWidget { + /// {@macro unreadThreadsBanner} + const UnreadThreadsBanner({ + super.key, + required this.unreadThreads, + this.onTap, + this.minHeight = 52, + this.margin = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + /// The set of all the unread threads. + final Set unreadThreads; + + /// Optional callback to handle tap events. + final VoidCallback? onTap; + + /// The minimum height of the banner. + /// + /// Defaults to 52. + final double minHeight; + + /// The margin applied to the banner. + /// + /// Defaults to `EdgeInsets.symmetric(horizontal: 8, vertical: 6)`. + final EdgeInsetsGeometry? margin; + + /// The padding applied to the banner. + /// + /// Defaults to `EdgeInsets.symmetric(horizontal: 16)`. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + if (unreadThreads.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = StreamChatTheme.of(context); + + return GestureDetector( + onTap: onTap, + child: Container( + margin: margin, + padding: padding, + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + color: theme.colorTheme.textHighEmphasis, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Text( + context.translations.newThreadsLabel( + count: unreadThreads.length, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.barsBg, + ), + ), + ), + StreamSvgIcon.reload( + color: theme.colorTheme.barsBg, + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart new file mode 100644 index 000000000..db1b5b420 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart @@ -0,0 +1,41 @@ +import 'package:flutter/cupertino.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; + +/// Represents a function type that formats a date. +typedef DateFormatter = String Function( + BuildContext context, + DateTime date, +); + +/// Formats the given [date] as a String. +String formatDate(BuildContext context, DateTime date) { + if (date.isToday) return Jiffy.parseFromDateTime(date).jm; + if (date.isYesterday) return context.translations.yesterdayLabel; + if (date.isWithinAWeek) return Jiffy.parseFromDateTime(date).EEEE; + + return Jiffy.parseFromDateTime(date).yMd; +} + +extension on DateTime { + bool get isToday { + final jiffyDate = Jiffy.parseFromDateTime(this); + final jiffyNow = Jiffy.parseFromDateTime(DateTime.now()); + + return jiffyDate.isSame(jiffyNow, unit: Unit.day); + } + + bool get isYesterday { + final jiffyDate = Jiffy.parseFromDateTime(this); + final jiffyNow = Jiffy.parseFromDateTime(DateTime.now()); + + return jiffyDate.isSame(jiffyNow.subtract(days: 1), unit: Unit.day); + } + + bool get isWithinAWeek { + final jiffyDate = Jiffy.parseFromDateTime(this); + final jiffyNow = Jiffy.parseFromDateTime(DateTime.now()); + + return jiffyDate.isAfter(jiffyNow.subtract(days: 7), unit: Unit.day); + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 5b22c5b05..d63c85deb 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -592,3 +592,40 @@ extension MessageListX on Iterable { return null; } } + +/// Useful extensions on [ChannelModel]. +extension ChannelModelX on ChannelModel { + /// Returns the channel name if exists, or a formatted name based on the + /// members of the channel and the [maxMembers] allowed. + String? formatName({ + User? currentUser, + int maxMembers = 2, + }) { + // If there's an assigned name and it's not empty, we use it. + if (name case final name? when name.isNotEmpty) return name; + + // If there are no members, we return null. + final members = this.members; + if (members == null) return null; + + final otherMembers = members.where((it) => it.userId != currentUser?.id); + + // If there are no other members, we return the name of the current user. + if (otherMembers.isEmpty) return currentUser?.name; + + // Otherwise, we return the names of the first `maxMembers` members sorted + // alphabetically, followed by the number of remaining members if there are + // more than `maxMembers` members. + final memberNames = otherMembers + .map((it) => it.user?.name) + .whereType() + .take(maxMembers) + .sorted(); + + return switch (otherMembers.length <= maxMembers) { + true => memberNames.join(', '), + false => + '${memberNames.join(', ')} + ${otherMembers.length - maxMembers}', + }; + } +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 9e25febc7..f59ecc689 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -106,6 +106,9 @@ export 'src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart'; export 'src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart'; export 'src/scroll_view/stream_scroll_view_empty_widget.dart'; export 'src/scroll_view/stream_scroll_view_indexed_widget_builder.dart'; +export 'src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart'; +export 'src/scroll_view/thread_scroll_view/stream_thread_list_view.dart'; +export 'src/scroll_view/thread_scroll_view/unread_threads_banner.dart'; export 'src/scroll_view/user_scroll_view/stream_user_grid_tile.dart'; export 'src/scroll_view/user_scroll_view/stream_user_grid_view.dart'; export 'src/scroll_view/user_scroll_view/stream_user_list_tile.dart'; diff --git a/packages/stream_chat_flutter/lib/svgs/reload.svg b/packages/stream_chat_flutter/lib/svgs/reload.svg new file mode 100644 index 000000000..4839c3672 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/reload.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_dark.png new file mode 100644 index 000000000..4e7d68932 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_light.png new file mode 100644 index 000000000..e8a6642fe Binary files /dev/null and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart b/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart new file mode 100644 index 000000000..f08263ae7 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart @@ -0,0 +1,46 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/timestamp.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +void main() { + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamTimestamp looks fine', + fileName: 'stream_timestamp_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return StreamTimestamp( + date: DateTime.parse('2021-07-20T16:00:00.000Z'), + style: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.textHighEmphasis, + ), + ); + }, + ), + ), + ); + } +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png new file mode 100644 index 000000000..6608eb69d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png new file mode 100644 index 000000000..869e94c16 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/unread_threads_banner_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/unread_threads_banner_dark.png new file mode 100644 index 000000000..a8b2e50f9 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/unread_threads_banner_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/unread_threads_banner_light.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/unread_threads_banner_light.png new file mode 100644 index 000000000..f917ecd95 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/unread_threads_banner_light.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart new file mode 100644 index 000000000..976df5f30 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_tile_test.dart @@ -0,0 +1,93 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + final user1 = User(id: 'uid1', name: 'User 1'); + final user2 = User(id: 'uid2', name: 'User 2'); + final createdAt = DateTime.parse('2021-07-20T16:00:00.000Z'); + final thread = Thread( + activeParticipantCount: 2, + channelCid: 'channel-type:channel-id', + channel: ChannelModel( + cid: 'channel-type:channel-id', + extraData: const {'name': 'Group ride'}, + ), + parentMessageId: 'parent-message-id', + parentMessage: Message( + id: 'parent-message-id', + text: "Hey everyone, who's up for a group ride this Saturday morning?", + ), + createdByUserId: 'uid1', + createdBy: user2, + participantCount: 2, + threadParticipants: [ + ThreadParticipant( + user: user1, + channelCid: '', + createdAt: createdAt, + lastReadAt: createdAt, + ), + ThreadParticipant( + user: user2, + channelCid: '', + createdAt: createdAt, + lastReadAt: createdAt, + ), + ], + lastMessageAt: createdAt, + createdAt: createdAt, + updatedAt: createdAt, + title: 'Group ride preparation and discussion', + replyCount: 1, + latestReplies: [ + Message( + id: 'mid1', + text: 'See you all there, stay safe on the roads!', + user: user1, + createdAt: createdAt, + updatedAt: createdAt, + ), + ], + read: [ + Read( + user: user2, + lastRead: createdAt, + unreadMessages: 3, + ), + ], + ); + + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamThreadListTile looks fine', + fileName: 'stream_thread_list_tile_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 600, height: 150), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamThreadListTile(thread: thread, currentUser: user2), + ), + ); + } +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatConfiguration( + data: StreamChatConfigurationData(), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/unread_threads_banner_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/unread_threads_banner_test.dart new file mode 100644 index 000000000..27a3425f8 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/unread_threads_banner_test.dart @@ -0,0 +1,35 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> UnreadThreadsBanner looks fine', + fileName: 'unread_threads_banner_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + const UnreadThreadsBanner(unreadThreads: {'id1', 'id2', 'id3'}), + ), + ); + } +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 7dae944f2..7c65984ed 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -416,10 +416,13 @@ class StreamChannelState extends State { void initState() { super.initState(); _populateFutures(); + + // Start watching the channel if it's not yet initialized. + if (channel.state == null) channel.watch(); } void _populateFutures() { - _futures = [widget.channel.initialized]; + _futures = [channel.initialized]; if (initialMessageId != null) { _futures.add(_loadChannelAtMessage); } else if (channel.state != null && channel.state!.unreadCount > 0) { @@ -447,8 +450,12 @@ class StreamChannelState extends State { @override void didUpdateWidget(covariant StreamChannel oldWidget) { - if (oldWidget.initialMessageId != initialMessageId) { + if (oldWidget.channel != channel || + oldWidget.initialMessageId != initialMessageId) { _populateFutures(); + + // Start watching the channel if it's not yet initialized. + if (channel.state == null) channel.watch(); } super.didUpdateWidget(oldWidget); } @@ -462,30 +469,28 @@ class StreamChannelState extends State { @override Widget build(BuildContext context) { - Widget child = FutureBuilder>( - future: Future.wait(_futures), - initialData: [ - channel.state != null, - _futures.length == 1, - ], - builder: (context, snapshot) { - if (snapshot.hasError) { - final error = snapshot.error!; - final stackTrace = snapshot.stackTrace; - return widget.errorBuilder(context, error, stackTrace); - } - - final dataLoaded = snapshot.data?.every((it) => it) == true; - if (widget.showLoading && !dataLoaded) { - return widget.loadingBuilder(context); - } - return widget.child; - }, + return Material( + child: FutureBuilder>( + future: Future.wait(_futures), + initialData: [ + channel.state != null, + _futures.length == 1, + ], + builder: (context, snapshot) { + if (snapshot.hasError) { + final error = snapshot.error!; + final stackTrace = snapshot.stackTrace; + return widget.errorBuilder(context, error, stackTrace); + } + + final dataLoaded = snapshot.data?.every((it) => it) == true; + if (widget.showLoading && !dataLoaded) { + return widget.loadingBuilder(context); + } + return widget.child; + }, + ), ); - if (_futures.length > 1) { - child = Material(child: child); - } - return child; } } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index 3195d2ea6..0d61e06d2 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -219,11 +219,7 @@ class StreamChannelListController extends PagedValueNotifier { _unsubscribeFromChannelListEvents(); } - _channelEventSubscription = client - .on() - .skip(1) // Skipping the last emitted event. - // We only need to handle the latest events. - .listen((event) { + _channelEventSubscription = client.on().listen((event) { // Only handle the event if the value is in success state. if (value.isNotSuccess) return; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 6cc845ea4..3ac1cfd20 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -183,11 +183,7 @@ class StreamPollVoteListController _unsubscribeFromPolVoteListEvents(); } - _pollVoteListEventSubscription = channel - .on() - .skip(1) // Skipping the last emitted event. - // We only need to handle the latest events. - .listen((event) { + _pollVoteListEventSubscription = channel.on().listen((event) { // Only handle the event if the value is in success state. if (value.isNotSuccess) return; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart new file mode 100644 index 000000000..978237705 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_thread_list_event_handler.dart'; + +/// The default thread list page limit to load. +const defaultThreadsPagedLimit = 10; + +const _kDefaultBackendPaginationLimit = 30; + +/// {@template streamThreadListController} +/// A controller for a thread list. +/// +/// This class lets you perform tasks such as: +/// * Load initial data. +/// * Load more data using [loadMore]. +/// * Replace the previously loaded threads. +/// {@endtemplate} +class StreamThreadListController extends PagedValueNotifier { + /// {@macro streamThreadListController} + StreamThreadListController({ + required this.client, + StreamThreadListEventHandler? eventHandler, + this.options = const ThreadOptions(), + this.limit = defaultThreadsPagedLimit, + }) : _activeOptions = options, + _eventHandler = eventHandler ?? StreamThreadListEventHandler(), + super(const PagedValue.loading()); + + /// Creates a [StreamThreadListController] from the passed [value]. + StreamThreadListController.fromValue( + super.value, { + required this.client, + StreamThreadListEventHandler? eventHandler, + this.options = const ThreadOptions(), + this.limit = defaultThreadsPagedLimit, + }) : _activeOptions = options, + _eventHandler = eventHandler ?? StreamThreadListEventHandler(); + + /// The Stream client used to perform the queries. + final StreamChatClient client; + + /// The channel event handlers to use for the channels list. + final StreamThreadListEventHandler _eventHandler; + + /// The limit to apply to the thread list. + /// + /// The default is set to [defaultUserPagedLimit]. + final int limit; + + /// The options used to filter the threads. + /// + /// The default is set to [ThreadOptions]. + final ThreadOptions options; + ThreadOptions _activeOptions; + + /// Allows for the change of the [options] at runtime. + /// + /// Use this if you need to support runtime option changes, + /// through custom filters UI. + set options(ThreadOptions options) => _activeOptions = options; + + /// The ids of the threads that have unseen messages. + ValueListenable> get unseenThreadIds => _unseenThreadIds; + final _unseenThreadIds = ValueNotifier>(const {}); + + /// Adds a new thread to the set of unseen thread IDs. + void addUnseenThreadId(String? threadId) { + if (threadId == null) return; + + final currentUnseenThreadIds = {..._unseenThreadIds.value}; + final updatedUnseenThreadIds = {...currentUnseenThreadIds, threadId}; + _unseenThreadIds.value = updatedUnseenThreadIds; + } + + /// Clears the set of unseen thread IDs. + void clearUnseenThreadIds() => _unseenThreadIds.value = const {}; + + @override + Future doInitialLoad() async { + final limit = min( + this.limit * defaultInitialPagedLimitMultiplier, + _kDefaultBackendPaginationLimit, + ); + try { + final response = await client.queryThreads( + options: _activeOptions, + pagination: PaginationParams(limit: limit), + ); + + final results = response.threads; + final nextKey = response.next; + value = PagedValue( + items: results, + nextPageKey: nextKey, + ); + // Start listening to events + _subscribeToThreadListEvents(); + } on StreamChatError catch (error) { + value = PagedValue.error(error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = PagedValue.error(chatError); + } + } + + @override + Future loadMore(String nextPageKey) async { + final previousValue = value.asSuccess; + + try { + final response = await client.queryThreads( + options: _activeOptions, + pagination: PaginationParams(limit: limit, next: nextPageKey), + ); + + final results = response.threads; + final previousItems = previousValue.items; + final newItems = previousItems + results; + final next = response.next; + final nextKey = next != null && next.isNotEmpty ? next : null; + value = PagedValue( + items: newItems, + nextPageKey: nextKey, + ); + } on StreamChatError catch (error) { + value = previousValue.copyWith(error: error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = previousValue.copyWith(error: chatError); + } + } + + @override + Future refresh({bool resetValue = true}) { + if (resetValue) { + _activeOptions = options; + } + return super.refresh(resetValue: resetValue); + } + + /// Replaces the previously loaded threads with the passed [threads]. + set threads(List threads) { + if (value.isSuccess) { + final currentValue = value.asSuccess; + value = currentValue.copyWith(items: threads); + } else { + value = PagedValue(items: threads); + } + } + + /// Returns the thread with the given [parentMessageId] from the list. + /// + /// Returns `null` if no thread is found. + Thread? getThread({required String? parentMessageId}) { + final currentThreads = [...currentItems]; + final thread = currentThreads.firstWhereOrNull( + (it) => it.parentMessageId == parentMessageId, + ); + + return thread; + } + + /// Updates the given [thread] in the list. + void updateThread(Thread thread) { + final currentThreads = [...currentItems]; + final updateIndex = currentThreads.indexWhere( + (it) => it.parentMessageId == thread.parentMessageId, + ); + + if (updateIndex < 0) return; + currentThreads[updateIndex] = thread; + + threads = currentThreads; + } + + /// Deletes the thread with the given [parentMessageId] from the list. + /// + /// Returns `true` if the thread is deleted successfully. Otherwise, `false`. + void deleteThread({required String? parentMessageId}) { + final currentThreads = [...currentItems]; + final removeIndex = currentThreads.indexWhere( + (it) => it.parentMessageId == parentMessageId, + ); + + if (removeIndex < 0) return; + currentThreads.removeAt(removeIndex); + + threads = currentThreads; + } + + /// Removes all the threads with the given [channelCid] from the list. + /// + /// This is useful when you want to remove all the threads from a channel + /// when the channel is deleted. + void deleteThreadByChannelCid({required String channelCid}) { + final oldThreads = [...currentItems]; + final newThreads = [ + ...oldThreads.where((it) => it.channelCid != channelCid), + ]; + + threads = newThreads; + } + + /// Event listener, which can be set in order to listen + /// [client] web-socket events. + /// + /// Return `true` if the event is handled. Return `false` to + /// allow the event to be handled internally. + bool Function(Event event)? eventListener; + + StreamSubscription? _threadEventSubscription; + + // Subscribes to the thread list events. + void _subscribeToThreadListEvents() { + if (_threadEventSubscription != null) { + _unsubscribeFromThreadListEvents(); + } + + _threadEventSubscription = client.on().listen((event) { + // Only handle the event if the value is in success state. + if (value.isNotSuccess) return; + + // Returns early if the event is already handled by the listener. + if (eventListener?.call(event) ?? false) return; + + final handlerFunc = switch (event.type) { + EventType.threadUpdated => _eventHandler.onThreadUpdated, + EventType.connectionRecovered => _eventHandler.onConnectionRecovered, + EventType.notificationThreadMessageNew => + _eventHandler.onNotificationThreadMessageNew, + EventType.messageRead => _eventHandler.onMessageRead, + EventType.notificationMarkUnread => + _eventHandler.onNotificationMarkUnread, + EventType.channelDeleted => _eventHandler.onChannelDeleted, + EventType.channelTruncated => _eventHandler.onChannelTruncated, + EventType.messageNew => _eventHandler.onMessageNew, + EventType.messageUpdated => _eventHandler.onMessageUpdated, + EventType.messageDeleted => _eventHandler.onMessageDeleted, + EventType.reactionNew => _eventHandler.onReactionNew, + EventType.reactionUpdated => _eventHandler.onReactionUpdated, + EventType.reactionDeleted => _eventHandler.onReactionDeleted, + _ => null, + }; + + return handlerFunc?.call(event, this); + }); + } + + // Unsubscribes from all channel list events. + void _unsubscribeFromThreadListEvents() { + if (_threadEventSubscription != null) { + _threadEventSubscription!.cancel(); + _threadEventSubscription = null; + } + } + + /// Pauses all subscriptions added to this composite. + void pauseEventsSubscription([Future? resumeSignal]) { + _threadEventSubscription?.pause(resumeSignal); + } + + /// Resumes all subscriptions added to this composite. + void resumeEventsSubscription() { + _threadEventSubscription?.resume(); + } + + @override + void dispose() { + _unsubscribeFromThreadListEvents(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_event_handler.dart new file mode 100644 index 000000000..7acb81491 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_event_handler.dart @@ -0,0 +1,446 @@ +import 'package:collection/collection.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_thread_list_controller.dart'; + +/// Contains handlers that are called from [StreamThreadListController] for +/// certain [Event]s. +/// +/// This class can be mixed in or extended to create custom overrides. +mixin class StreamThreadListEventHandler { + /// Function which gets called for the event [EventType.threadUpdated]. + /// + /// This event is fired when a thread is updated. + /// + /// By default, this does nothing. Override this method to handle this event. + void onThreadUpdated(Event event, StreamThreadListController controller) { + // no-op + } + + /// Function which gets called for the event [EventType.connectionRecovered]. + /// + /// This event is fired when the client web-socket connection recovers. + /// + /// By default, this refreshes the whole thread list. + void onConnectionRecovered( + Event event, + StreamThreadListController controller, + ) { + controller.refresh(); + } + + /// Function which gets called for the event [EventType.reactionNew]. + /// + /// + /// This event is fired when a new reaction is added to a message. + /// + /// By default, this updates the parent or reply message in the thread list. + void onReactionNew( + Event event, + StreamThreadListController controller, + ) { + final message = event.message; + if (message == null) return; + + return _updateParentOrReply(message, controller); + } + + /// Function which gets called for the event [EventType.reactionUpdated]. + /// + /// This event is fired when a reaction is updated. + /// + /// By default, this updates the parent or reply message in the thread list. + void onReactionUpdated( + Event event, + StreamThreadListController controller, + ) { + final message = event.message; + if (message == null) return; + + return _updateParentOrReply(message, controller); + } + + /// Function which gets called for the event [EventType.reactionDeleted]. + /// + /// This event is fired when a reaction is deleted. + /// + /// By default, this updates the parent or reply message in the thread list. + void onReactionDeleted( + Event event, + StreamThreadListController controller, + ) { + final message = event.message; + if (message == null) return; + + return _updateParentOrReply(message, controller); + } + + /// Function which gets called for the event + /// [EventType.notificationThreadMessageNew]. + /// + /// This event is fired when a new message is added to a thread. + /// + /// By default, this updates the unread count of the channel. + void onNotificationThreadMessageNew( + Event event, + StreamThreadListController controller, + ) { + final message = event.message; + if (message == null) return; + + final parentMessageId = message.parentId; + final thread = controller.getThread(parentMessageId: parentMessageId); + + // Thread is not (yet) loaded, just update the state of unseenThreadIds. + if (thread == null) return controller.addUnseenThreadId(parentMessageId); + + // Loaded thread is already being handled by the [onMessageNew] and + // [onMessageUpdated] handlers. + return; + } + + /// Function which gets called for the event [EventType.messageNew]. + /// + /// This event is fired when a new message is added. + /// + /// By default, this updates the parent or reply message in the thread list. + void onMessageNew( + Event event, + StreamThreadListController controller, + ) { + final message = event.message; + if (message == null) return; + + return _updateParentOrReply(message, controller); + } + + /// Function which gets called for the event [EventType.messageUpdated]. + /// + /// This event is fired when a message is updated. + /// + /// By default, this updates the parent or reply message in the thread list. + void onMessageUpdated( + Event event, + StreamThreadListController controller, + ) { + final message = event.message; + if (message == null) return; + + return _updateParentOrReply(message, controller); + } + + /// Function which gets called for the event [EventType.messageDeleted]. + /// + /// This event is fired when a message is deleted. + /// + /// By default, this updates or deletes the parent/reply message in the + /// thread list based on the [Event.hardDelete] value. + void onMessageDeleted( + Event event, + StreamThreadListController controller, + ) { + final message = event.message; + if (message == null) return; + + return _deleteParentOrReply( + message, + controller, + isHardDelete: event.hardDelete ?? false, + ); + } + + /// Function which gets called for the event [EventType.channelDeleted]. + /// + /// This event is fired when a channel is deleted. + /// + /// By default, this deletes all threads associated with the channel. + void onChannelDeleted( + Event event, + StreamThreadListController controller, + ) { + final channelCid = event.cid ?? event.channel?.cid; + if (channelCid == null) return; + + return controller.deleteThreadByChannelCid(channelCid: channelCid); + } + + /// Function which gets called for the event [EventType.channelTruncated]. + /// + /// This event is fired when a channel is truncated. + /// + /// By default, this deletes all threads associated with the channel. + void onChannelTruncated( + Event event, + StreamThreadListController controller, + ) { + final channelCid = event.cid ?? event.channel?.cid; + if (channelCid == null) return; + + return controller.deleteThreadByChannelCid(channelCid: channelCid); + } + + /// Function which gets called for the event [EventType.messageRead]. + /// + /// This event is fired when a message is marked as read. + /// + /// By default, this updates the read state of the thread. + void onMessageRead( + Event event, + StreamThreadListController controller, + ) { + final thread = event.thread; + if (thread == null) return; + + final user = event.user; + if (user == null) return; + + final createdAt = event.createdAt; + + return _markThreadAsRead(thread, user, createdAt, controller); + } + + /// Function which gets called for the event + /// [EventType.notificationMarkUnread]. + /// + /// This event is fired when a message is marked as unread. + /// + /// By default, this updates the read state of the thread. + void onNotificationMarkUnread( + Event event, + StreamThreadListController controller, + ) { + final thread = event.thread; + if (thread == null) return; + + final user = event.user; + if (user == null) return; + + final createdAt = event.createdAt; + + return _markThreadAsUnread(thread, user, createdAt, controller); + } + + void _markThreadAsRead( + Thread threadInfo, + User user, + DateTime createdAt, + StreamThreadListController controller, + ) { + final parentMessageId = threadInfo.parentMessageId; + final thread = controller.getThread(parentMessageId: parentMessageId); + if (thread == null) return; + + final updatedThread = thread.markAsReadByUser(user, createdAt); + + return controller.updateThread(updatedThread); + } + + void _markThreadAsUnread( + Thread threadInfo, + User user, + DateTime createdAt, + StreamThreadListController controller, + ) { + final parentMessageId = threadInfo.parentMessageId; + final thread = controller.getThread(parentMessageId: parentMessageId); + if (thread == null) return; + + final updatedThread = thread.markAsUnreadByUser(user, createdAt); + + return controller.updateThread(updatedThread); + } + + void _updateParentOrReply( + Message message, + StreamThreadListController controller, + ) { + // If the message is a parent message, update the thread. + final thread = controller.getThread(parentMessageId: message.id); + if (thread != null) { + final updatedThread = thread.updateParent(message); + return controller.updateThread(updatedThread); + } + + // Otherwise, if the message is a reply, upsert it in the thread. + final parentMessageId = message.parentId; + final parentThread = controller.getThread(parentMessageId: parentMessageId); + if (parentThread != null) { + final updatedThread = parentThread.upsertReply(message); + return controller.updateThread(updatedThread); + } + } + + void _deleteParentOrReply( + Message message, + StreamThreadListController controller, { + bool isHardDelete = false, + }) { + // If the message is hard deleted, and it is a parent message, delete the + // thread. Otherwise, remove the message from the thread replies. + if (isHardDelete) { + final parentMessageId = message.parentId; + if (parentMessageId == null) { + return controller.deleteThread(parentMessageId: parentMessageId); + } + + return controller.deleteReply(message); + } + + // Otherwise, update the parent or reply message. + return _updateParentOrReply(message, controller); + } +} + +/// Extension on [StreamThreadListController] that contains utility methods +/// to update the threads list. +extension StreamThreadListEventHandlerExtension on StreamThreadListController { + /// Updates the parent message of a thread. + /// + /// Returns `true` if matching parent message was found and was updated, + /// `false` otherwise. + void updateParent(Message parent) { + final thread = getThread(parentMessageId: parent.id); + if (thread == null) return; // No thread found for the message. + + final updatedThread = thread.updateParent(parent); + + return updateThread(updatedThread); + } + + /// Deletes the given [reply] from the appropriate thread. + void deleteReply(Message reply) { + final thread = getThread(parentMessageId: reply.parentId); + if (thread == null) return; // No thread found for the message. + + final updatedThread = thread.deleteReply(reply); + + return updateThread(updatedThread); + } + + /// Inserts/updates the given [reply] into the appropriate thread. + void upsertReply(Message reply) { + final thread = getThread(parentMessageId: reply.parentId); + if (thread == null) return; // No thread found for the message. + + final updatedThread = thread.upsertReply(reply); + + return updateThread(updatedThread); + } +} + +extension on Thread { + /// Updates the parent message of a Thread. + Thread updateParent(Message parent) { + // Skip update if [parent] is not related to this Thread. + if (parentMessageId != parent.id) return this; + + return copyWith( + parentMessage: parent, + deletedAt: parent.deletedAt, + updatedAt: parent.updatedAt, + ); + } + + /// Inserts a new [reply] (or updates and existing one) into the Thread. + Thread upsertReply(Message reply) { + // Skip update if [reply] is not related to this Thread. + if (parentMessageId != reply.parentId) return this; + + final updatedReplies = _upsertMessageInList(reply, latestReplies); + final isInsert = updatedReplies.length > latestReplies.length; + final sortedUpdatedReplies = updatedReplies.sortedBy( + (it) => it.localCreatedAt ?? it.createdAt, + ); + + final lastMessage = sortedUpdatedReplies.lastOrNull; + final lastMessageAt = lastMessage?.localCreatedAt ?? lastMessage?.createdAt; + + // Update read counts (+1 for each non-sender of the message). + final updatedRead = isInsert ? _updateReadCounts(read, reply) : read; + + return copyWith( + updatedAt: lastMessageAt, + lastMessageAt: lastMessageAt, + latestReplies: sortedUpdatedReplies, + read: updatedRead, + ); + } + + Thread deleteReply(Message reply) { + // Skip update if [reply] is not related to this Thread. + if (parentMessageId != reply.parentId) return this; + + final updatedReplies = latestReplies.where((it) => it.id != reply.id); + final sortedUpdatedReplies = updatedReplies.sortedBy( + (it) => it.localCreatedAt ?? it.createdAt, + ); + + final lastMessage = sortedUpdatedReplies.lastOrNull; + final lastMessageAt = lastMessage?.localCreatedAt ?? lastMessage?.createdAt; + + return copyWith( + updatedAt: lastMessageAt, + lastMessageAt: lastMessageAt, + latestReplies: sortedUpdatedReplies, + ); + } + + /// Marks the given thread as read by the given [user]. + Thread markAsReadByUser(User user, DateTime createdAt) { + final updatedRead = read?.map((read) { + if (read.user.id == user.id) { + return read.copyWith( + user: user, + unreadMessages: 0, + lastRead: createdAt, + ); + } + return read; + }).toList(); + + return copyWith(read: updatedRead); + } + + /// Marks the given thread as unread by the given [user]. + Thread markAsUnreadByUser(User user, DateTime createdAt) { + final updatedRead = read?.map((read) { + if (read.user.id == user.id) { + return read.copyWith( + user: user, + // Update this value to what the backend returns (when implemented) + unreadMessages: read.unreadMessages + 1, + lastRead: createdAt, + ); + } + return read; + }).toList(); + + return copyWith(read: updatedRead); + } + + List _upsertMessageInList( + Message newMessage, + List messages, + ) { + // Insert if message is not present in the list. + if (messages.none((it) => it.id == newMessage.id)) { + return [...messages, newMessage]; + } + + // Otherwise, update the message. + return [ + ...messages.map((message) { + if (message.id == newMessage.id) return newMessage; + return message; + }), + ]; + } + + List? _updateReadCounts(List? read, Message reply) { + return read?.map((userRead) { + // Skip the sender of the message. + if (userRead.user.id == reply.user?.id) return userRead; + // Increment the unread count for the non-sender. + return userRead.copyWith(unreadMessages: userRead.unreadMessages + 1); + }).toList(); + } +} diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index feaa78a8d..cbdd0601c 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -19,5 +19,6 @@ export 'src/stream_message_input_controller.dart'; export 'src/stream_message_search_list_controller.dart'; export 'src/stream_poll_controller.dart'; export 'src/stream_poll_vote_list_controller.dart'; +export 'src/stream_thread_list_controller.dart'; export 'src/stream_user_list_controller.dart'; export 'src/typedef.dart'; diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index a909bdb27..db15ac105 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -623,6 +623,15 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get loadingPollVotesError => 'Error loading poll votes'; + + @override + String get repliedToLabel => 'replied to:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 new thread'; + return '$count new threads'; + } } void main() async { diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 15919c30d..52d2c9d09 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -604,4 +604,13 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get loadingPollVotesError => 'Error en carregar els vots'; + + @override + String get repliedToLabel => 'resposta a:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 fil nou'; + return '$count fils nous'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index c771939f5..da184bc88 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -598,4 +598,13 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get loadingPollVotesError => 'Fehler beim Laden der Umfrage-Stimmen'; + + @override + String get repliedToLabel => 'antwortete:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 neuer Thread'; + return '$count neue Threads'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 60c961aae..d4b59b05c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -600,4 +600,13 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get loadingPollVotesError => 'Error loading poll votes'; + + @override + String get repliedToLabel => 'replied to:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 new thread'; + return '$count new threads'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index cb8860a70..10647823c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -606,4 +606,13 @@ No es posible añadir más de $limit archivos adjuntos @override String get loadingPollVotesError => 'Error al cargar los votos de la encuesta'; + + @override + String get repliedToLabel => 'respondido a:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 nuevo hilo'; + return '$count nuevos hilos'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 5593aeaf7..e09746980 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -609,4 +609,13 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get loadingPollVotesError => 'Erreur de chargement des votes du sondage'; + + @override + String get repliedToLabel => 'répondu à:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 Nouveau fil'; + return '$count Nouveaux fils'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index c8a097ca8..6e3113ed5 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -600,4 +600,13 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { } return 'वोट'; } + + @override + String get repliedToLabel => 'जवाब दिया:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 नया थ्रेड'; + return '$count नए थ्रेड्स'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index c3c2402b1..c4a08edb5 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -609,4 +609,13 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get loadingPollVotesError => 'Errore durante il caricamento dei voti del sondaggio'; + + @override + String get repliedToLabel => 'risposto a:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 nuovo thread'; + return '$count nuovi thread'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 045d7d009..75036bf52 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -582,4 +582,12 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get loadingPollVotesError => '投票の読み込みエラー'; + + @override + String get repliedToLabel => '返信先:'; + + @override + String newThreadsLabel({required int count}) { + return '$count 件の新しいスレッド'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 4a5ec87b3..fb3090b86 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -583,4 +583,12 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get loadingPollVotesError => '투표 로딩 오류'; + + @override + String get repliedToLabel => '회신:'; + + @override + String newThreadsLabel({required int count}) { + return '$count개의 새 스레드'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 9712a3fc7..05a8ed298 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -591,4 +591,13 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get loadingPollVotesError => 'Feil ved lasting av stemmer'; + + @override + String get repliedToLabel => 'svarte på:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 ny tråd'; + return '$count nye tråder'; + } } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index ded1eabf4..c05549659 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -603,4 +603,13 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get loadingPollVotesError => 'Erro ao carregar os votos'; + + @override + String get repliedToLabel => 'respondeu a:'; + + @override + String newThreadsLabel({required int count}) { + if (count == 1) return '1 novo tópico'; + return '$count novos tópicos'; + } } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 2df41cf80..182b4bbe1 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -287,6 +287,8 @@ void main() { // Vote count expect(localizations.voteCountLabel(), isNotNull); expect(localizations.voteCountLabel(count: 3), isNotNull); + expect(localizations.repliedToLabel, isNotNull); + expect(localizations.newThreadsLabel(count: 3), isNotNull); }); }