diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml index eddb2ac1..4e7c3b5f 100644 --- a/.github/workflows/pr_title.yml +++ b/.github/workflows/pr_title.yml @@ -1,4 +1,4 @@ -name: 'PR Title is Conventional' +name: 'PR is Conventional and Semantic' on: pull_request_target: types: @@ -9,7 +9,7 @@ on: - develop jobs: - main: + conventional_pr_title: runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v3.4.0 @@ -25,3 +25,20 @@ jobs: requireScope: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + semantic_changelog_update: + needs: conventional_pr_title # Trigger after the [conventional_pr_title] completes + runs-on: ubuntu-latest + steps: + - uses: GetStream/verify-semantic-changelog-update@main + with: + scopes: | + { + "llc": "packages/stream_chat", + "ui": "packages/stream_chat_flutter", + "core": "packages/stream_chat_flutter_core", + "localization": "packages/stream_chat_flutter_localizations", + "persistence": "packages/stream_chat_persistence" + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0faad799..3bb5a923 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,8 @@ Stream's Flutter code is kept in a single mono-repository consisting of multiple `stream_chat_persistence` - This package provides a persistence client for fetching and saving chat data locally. Stream Chat Persistence uses Moor as a disk cache. +`stream_chat_localizations` - This package provides a set of localizations for the SDK. + ### Local Setup Congratulations! 🎉. You've successfully cloned our repo, and you are ready to make your first contribution. Before you can start making code changes, there are a few things to configure. diff --git a/README.md b/README.md index 6758b189..62358d57 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This repository contains code for our [Dart](https://dart.dev/) and [Flutter](ht Stream allows developers to rapidly deploy scalable feeds and chat messaging with an industry leading 99.999% uptime SLA guarantee. ## Sample apps and demos -Our team maintains a dedicated repository for fully-fledged sample applications and demos. Consider checking out [GetStream/flutter-samples](https://github.com/GetStream/flutter-samples) to learn more or get started by looking at our latest [Stream Chat demo](https://github.com/GetStream/flutter-samples/tree/main/stream_chat_v1). +Our team maintains a dedicated repository for fully-fledged sample applications and demos. Consider checking out [GetStream/flutter-samples](https://github.com/GetStream/flutter-samples) to learn more or get started by looking at our latest [Stream Chat demo](https://github.com/GetStream/flutter-samples/tree/main/packages/stream_chat_v1). ## Free for Makers diff --git a/docusaurus/docs/Flutter/basics/introduction.mdx b/docusaurus/docs/Flutter/basics/introduction.mdx index 756bd33f..99170774 100644 --- a/docusaurus/docs/Flutter/basics/introduction.mdx +++ b/docusaurus/docs/Flutter/basics/introduction.mdx @@ -8,13 +8,14 @@ Exploring The Basics Of Stream Chat ![](../assets/sdk_title.png) -Stream Chat is a service that helps you easily build a full chat experience in your Flutter (and more) apps. +Stream Chat is a service that helps you easily build a full chat experience in your Flutter apps. +We also support a variety of other SDKs. This section of the documentation focuses on our Flutter SDK which helps you easily -ship high quality messaging experiences in apps and programs built with the [Flutter toolkit made -by Google](https://flutter.dev). +ship high quality messaging experiences in apps and programs built with the [Flutter toolkit +made by Google](https://flutter.dev). -The Stream Chat Flutter SDK comprises of four different packages to choose from ranging from ones +The Stream Chat Flutter SDK comprises five different packages to choose from, ranging from ones giving you complete control to ones that give you a rich out-of-the-box chat experience. The packages that make up the Stream Chat SDK are: @@ -30,28 +31,28 @@ reusable and customisable UI components. saving chat data locally. 5. Localizations (stream_chat_localizations): provides a set of localizations for the SDK. -We recommend building prototypes using the full UI package since it contains UI widgets already -integrated with Stream's API. [stream_chat_flutter](https://pub.dev/packages/stream_chat_flutter) -is the fastest way to get up and running using Stream chat in your app. +We recommend building prototypes using the full UI package, [stream_chat_flutter](https://pub.dev/packages/stream_chat_flutter), +since it contains UI widgets already integrated with Stream's API. It is the fastest way to get up +and running using Stream chat in your app. The Flutter SDK enables you to build any type of chat or messaging experience for Android, iOS, Web and Desktop. If you're building a very custom UI and would prefer a more lean package, -our [core package](https://pub.dev/packages/stream_chat_flutter) will be suited to this use case. Core allows you to build custom, -expressive UIs while retaining the benefits of our full Flutter SDK. -APIs for accessing and controlling users, sending messages, etc are seamlessly integrated into -this package and accessible via providers and builders. +[stream_chat_flutter_core](https://pub.dev/packages/stream_chat_flutter_core) will be suited to this +use case. Core allows you to build custom, expressive UIs while retaining the benefits of our full +Flutter SDK. APIs for accessing and controlling users, sending messages, and so forth are seamlessly integrated +into this package and accessible via providers and builders. Before going into the docs, let's take a small detour to look at how the elements of Stream Chat are structured. ### Basic Structure -There are two core elements in chat, Users and Channels. +There are two core elements in chat, Users and Channels. Channels are groups of one or more users that can message each other. In an app, you need to have a user connected to query channels. -There is no specific distinction between a chat between two people and a group chat, +There is no specific distinction between a chat with only two people and a group chat, but there is a way to create a unique chat between a certain number of people by creating a distinct channel. ![](../assets/chat_basics.png) @@ -68,7 +69,7 @@ While this is a simplistic overview of the service, the Flutter SDK handles the Before reading the docs, consider trying our [online API tour](https://getstream.io/chat/get_started/), it is a nice way to learn how the API works. -It's in-browser so Javascript-based but the ideas are pretty much the same as Dart. +It's in-browser so you'll need to use Javascript but the core conceps are pretty much the same as Dart. You may also like to look at the [Flutter tutorial](https://getstream.io/chat/flutter/tutorial/) which focuses on using the UI package to get Stream Chat integrated into a Flutter app. diff --git a/docusaurus/docs/Flutter/guides/adding_custom_attachments.mdx b/docusaurus/docs/Flutter/guides/adding_custom_attachments.mdx index fb1f7bbe..9fe7f6aa 100644 --- a/docusaurus/docs/Flutter/guides/adding_custom_attachments.mdx +++ b/docusaurus/docs/Flutter/guides/adding_custom_attachments.mdx @@ -146,21 +146,25 @@ Next, we build the Static Maps URL (Add your API key before using the code snipp } ``` -And then modify the MessageListView and tell it how to build a location attachment: +And then modify the `MessageListView` and tell it how to build a location attachment, using the `messageBuilder` property and copying the default message implementation overriding the `customAttachmentBuilders` property: ```dart MessageListView( - customAttachmentBuilders: { - 'location': (context, message, attachments) { - var attachmentWidget = Image.network( - _buildMapAttachment( - attachments[0].extraData['latitude'], - attachments[0].extraData['longitude'], - ), - ); + messageBuilder: (context, details, messages, defaultMessage) { + return defaultMessage.copyWith( + customAttachmentBuilders: { + 'location': (context, message, attachments) { + final attachmentWidget = Image.network( + _buildMapAttachment( + attachments[0].extraData['latitude'], + attachments[0].extraData['longitude'], + ), + ); - return wrapAttachmentWidget(context, attachmentWidget, null, true, BorderRadius.circular(8.0)); - } + return wrapAttachmentWidget(context, attachmentWidget, null, true, BorderRadius.circular(8.0)); + } + }, + ); }, ), ``` diff --git a/docusaurus/docs/Flutter/guides/adding_local_data_persistence.mdx b/docusaurus/docs/Flutter/guides/adding_local_data_persistence.mdx new file mode 100644 index 00000000..e6283ca0 --- /dev/null +++ b/docusaurus/docs/Flutter/guides/adding_local_data_persistence.mdx @@ -0,0 +1,76 @@ +--- +id: adding_local_data_persistence +sidebar_position: 9 +title: Adding Local Data Persistence +--- + +Adding Local Data Persistence + +### Introduction + +Most messaging apps need to work regardless of whether the app is currently connected to the internet. +Local data persistence stores the fetched data from the backend on a local SQLite database using the +moor package in Flutter. All packages in the SDK can use local data persistence to store messages +across multiple platforms. + +### Implementation + +To add data persistence you can extend the class ChatPersistenceClient and pass an instance to the StreamChatClient. + +```dart +class CustomChatPersistentClient extends ChatPersistenceClient { +... +} + +final client = StreamChatClient( + apiKey ?? kDefaultStreamApiKey, + logLevel: Level.INFO, +)..chatPersistenceClient = CustomChatPersistentClient(); +``` + +We provide an official persistent client in the [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) +package that works using the library [moor](https://moor.simonbinder.eu), an SQLite ORM. + +Add this to your package's `pubspec.yaml` file, using the latest version. + +```yaml +dependencies: + stream_chat_persistence: ^latest_version +``` + +You should then run `flutter packages get` + +The usage is pretty simple. + +1. Create a new instance of `StreamChatPersistenceClient` providing `logLevel` and `connectionMode` + +```dart +final chatPersistentClient = StreamChatPersistenceClient( + logLevel: Level.INFO, + connectionMode: ConnectionMode.background, +); +``` + +2. Pass the instance to the official `StreamChatClient` + +```dart + final client = StreamChatClient( + apiKey ?? kDefaultStreamApiKey, + logLevel: Level.INFO, + )..chatPersistenceClient = chatPersistentClient; +``` + +And you are ready to go... + +Note that passing `ConnectionMode.background` the database uses a background isolate to unblock the main thread. +The `StreamChatClient` uses the `chatPersistentClient` to synchronize the database with the newest +information every time it receives new data about channels/messages/users. + +### Multi-user + +The DB file is named after the `userId`, so if you instantiate a client using a different `userId` you will use a different database. +Calling `client.disconnectUser(flushChatPersistence: true)` flushes all current database data. + +### Updating/deleting/sending a message while offline + +The information about the action is saved in offline storage. When the client returns online, everything is retried. diff --git a/docusaurus/docs/Flutter/guides/adding_localization.mdx b/docusaurus/docs/Flutter/guides/adding_localization.mdx index 37f7299d..4ad5eede 100644 --- a/docusaurus/docs/Flutter/guides/adding_localization.mdx +++ b/docusaurus/docs/Flutter/guides/adding_localization.mdx @@ -1,7 +1,7 @@ --- id: adding_localization sidebar_position: 2 -title: Adding Localization +title: Adding Localization (l10n) / Internationalization (i18n) --- Adding Localization To UI Widgets @@ -14,7 +14,7 @@ We have a dedicated package for adding localization to our UI widgets. It's call ## What is Localization? -If you deploy your app to users who speak another language, you'll need to internationalize (localize) it. That means you need to write the app in a way that makes it possible to localize values like text and layouts for each language or locale that the app supports. For more information, see the [Flutter documentation](https://flutter.dev/docs/development/accessibility-and-localization/**internationalization**). +If you deploy your app to users who speak another language, you'll need to internationalize (localize) it. That means you need to write the app in a way that makes it possible to localize values like text and layouts for each language or locale that the app supports. For more information, see the [Flutter documentation](https://flutter.dev/docs/development/accessibility-and-localization/internationalization). What this package allows you to do is to provide localized strings for the Stream chat widgets. For example, depending on the application locale, the Stream Chat widgets will display the appropriate language. The locale will be set automatically, based on system preferences, or you could set it programmatically in your app. The package supports several different languages, with more to be added. The package allows you to override any supported language or add a new language that isn't supported. @@ -26,7 +26,8 @@ At the moment we support the following languages: - [Italian](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart) - [French](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart) - [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) - +- [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart) +- [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart) More languages will be added in the future. Feel free to [contribute](https://github.com/GetStream/stream-chat-flutter/blob/master/CONTRIBUTING.md) to add more languages. ### Add dependency @@ -64,6 +65,8 @@ class MyApp extends StatelessWidget { Locale('fr'), Locale('it'), Locale('es'), + Locale('ja'), + Locale('ko'), ], // Add GlobalStreamChatLocalizations.delegates localizationsDelegates: GlobalStreamChatLocalizations.delegates, @@ -133,6 +136,8 @@ Here is an example of how that would look like: Locale('fr'), Locale('it'), Locale('es'), + Locale('ja'), + Locale('ko'), ], // locales are the locales of the device // supportedLocales are the app supported locales @@ -175,6 +180,8 @@ Example: nb fr it - es + es + ja + ko ``` diff --git a/docusaurus/docs/Flutter/guides/understanding_filters.mdx b/docusaurus/docs/Flutter/guides/understanding_filters.mdx new file mode 100644 index 00000000..9d169a80 --- /dev/null +++ b/docusaurus/docs/Flutter/guides/understanding_filters.mdx @@ -0,0 +1,143 @@ +--- +id: understanding_filters +sidebar_position: 10 +title: Understanding Filters +--- + +Understanding Filters + +### Introduction + +Filters are used to get a specific subset of objects (channels, users, messages, members, etc) which +fit the conditions specified. Earlier versions of the SDK contained String-based filters which are now replaced by type-safe +filters. This guide aims to explain the different types of filters and how to use them. + +### Types Of Filters + +#### Filter.equal + +The 'equal' filter gets the objects where the given key has the specified value. + +```dart +Filter.equal('type', 'messaging'), +``` + +#### Filter.notEqual + +The 'notEqual' filter gets the objects where the given key does not have the specified value. + +```dart +Filter.notEqual('type', 'messaging'), +``` + +#### Filter.greater + +The 'greater' filter gets the objects where the given key has a higher value than the specified value. + +```dart +Filter.greater('count', 5), +``` + +#### Filter.greaterOrEqual + +The 'greaterOrEqual' filter gets the objects where the given key has an equal or higher value than the specified value. + +```dart +Filter.greaterOrEqual('count', 5), +``` + +#### Filter.less + +The 'less' filter gets the objects where the given key has a lesser value than the specified value. + +```dart +Filter.less('count', 5), +``` + +#### Filter.lessOrEqual + +The 'lessOrEqual' filter gets the objects where the given key has a lesser or equal value than the specified value. + +```dart +Filter.lessOrEqual('count', 5), +``` + +#### Filter.in_ + +The 'in_' filter allows getting objects where the key matches any in a specified array. + +```dart +Filter.in_('members', [user.id]) +``` + +:::note +Since 'in' is a keyword in Dart, the filter has an underscore added. This does not apply to the 'notIn' +keyword. +::: + +#### Filter.notIn + +The 'notIn' filter allows getting objects where the key matches none in a specified array. + +```dart +Filter.notIn('members', [user.id]) +``` + +#### Filter.query + +The 'query' filter matches values by performing text search with the specified value. + +```dart +Filter.query('name', 'demo') +``` + +#### Filter.autoComplete + +The 'autoComplete' filter matches values with the specified prefix. + +```dart +Filter.autoComplete('name', 'demo') +``` + +#### Filter.exists + +The 'exists' filter matches values that exist, or don't exist, based on the specified boolean value. + +```dart +Filter.exists('name', true) +``` + +### Group Queries + +#### Filter.and + +The 'and' operator combines multiple queries. + +```dart +final filter = Filter.and([ + Filter.equal('type', 'messaging'), + Filter.in_('members', [user.id]) +]) +``` + +#### Filter.or + +Combines the provided filters and matches the values matched by at least one of the filters. + +```dart +final filter = Filter.or([ + Filter.in_('bannedUsers', [user.id]), + Filter.in_('shadowBannedUsers', [user.id]) +]) +``` + +#### Filter.nor + +Combines the provided filters and matches the values not matched by all the filters. + +```dart +final filter = Filter.nor([ + Filter.in_('bannedUsers', [user.id]), + Filter.in_('shadowBannedUsers', [user.id]) +]) +``` diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index e3e31db6..85bc73ef 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -8,7 +8,9 @@ - `User` and `OwnUser` classes now have an `image` property. Setting an image will also set the 'image' key on `extraData`, so `user.image` and `user.extraData['image']` is the same. - `User` and `OwnUser` classes now have a `name` property. Setting a name will also set the 'name' key on `extraData`, so `user.name` and `user.extraData['name']` is the same. - +- `Channel` class now has extra `image` getter and setter. As well as an `updateImage` to do a partial update after a channel has been initialized. +- `Channel` class now has extra `name` getter and setter. As well as an `updateName` to do a partial update after a channel has been initialized. +- Added slow mode which allows a cooldown period after a user sends a message. ## 2.1.1 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index a05587ab..22738794 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -15,20 +15,77 @@ import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/stream_chat.dart'; -/// This a the class that manages a specific channel. +/// Class that manages a specific channel. +/// +/// #### Channel name +/// +/// {@template name} +/// If an optional [name] argument is provided in the constructor then it +/// will be set on [extraData] with a key of 'name'. +/// +/// ```dart +/// final channel = Channel(client, type, id, name: 'Channel name'); +/// print(channel.name == channel.extraData['name']); // true +/// ``` +/// +/// Before the channel is initialized the name can be set directly: +/// ```dart +/// channel.name = 'New channel name'; +/// ``` +/// +/// To update the name after the channel has been initialized, call: +/// ```dart +/// channel.updateName('Updated channel name'); +/// ``` +/// +/// This will do a partial update to update the name. +/// {@endtemplate} +/// +/// #### Channel image +/// +/// {@template image} +/// If an optional [image] argument is provided in the constructor then it +/// will be set on [extraData] with a key of 'image'. +/// +/// ```dart +/// final channel = Channel(client, type, id, image: 'https://getstream.io/image.png'); +/// print(channel.image == channel.extraData['image']); // true +/// ``` +/// +/// Before the channel is initialized the image can be set directly: +/// ```dart +/// channel.image = 'https://getstream.io/new-image'; +/// ``` +/// +/// To update the image after the channel has been initialized, call: +/// ```dart +/// channel.updateImage('https://getstream.io/new-image'); +/// ``` +/// +/// This will do a partial update to update the image. +/// {@endtemplate} class Channel { - /// Create a channel client instance. + /// Class that manages a specific channel. + /// + /// Optional [extraData] and [image] properties can be provided. The [image] + /// is exposed to easily set a key of 'image' on [extraData]. Channel( this._client, this._type, this._id, { + String? name, + String? image, Map? extraData, }) : _cid = _id != null ? '$_type:$_id' : null, - _extraData = extraData ?? {} { - _client.logger.info('New Channel instance not initialized created'); + _extraData = { + ...?extraData, + if (name != null) 'name': name, + if (image != null) 'image': image, + } { + _client.logger.info('New Channel instance created, not yet initialized'); } - /// Create a channel client instance from a [ChannelState] object + /// Create a channel client instance from a [ChannelState] object. Channel.fromState(this._client, ChannelState channelState) : assert( channelState.channel != null, @@ -40,7 +97,7 @@ class Channel { _extraData = channelState.channel!.extraData { state = ChannelClientState(this, channelState); _initializedCompleter.complete(true); - _client.logger.info('New Channel instance initialized created'); + _client.logger.info('New Channel instance initialized'); } /// This client state @@ -53,155 +110,188 @@ class Channel { String? _cid; final Map _extraData; + /// Shortcut to set channel name. + /// + /// {@macro name} + set name(String? name) { + if (_initializedCompleter.isCompleted) { + throw StateError( + 'Once the channel is initialized you should use `channel.updateName` ' + 'to update the channel name', + ); + } + _extraData.addAll({'name': name}); + } + + /// Shortcut to set channel image. + /// + /// {@macro image} + set image(String? image) { + if (_initializedCompleter.isCompleted) { + throw StateError( + 'Once the channel is initialized you should use `channel.updateImage` ' + 'to update the channel image', + ); + } + _extraData.addAll({'image': image}); + } + set extraData(Map extraData) { if (_initializedCompleter.isCompleted) { throw StateError( - 'Once the channel is initialized you should use channel.update ' + 'Once the channel is initialized you should use `channel.update` ' 'to update channel data', ); } _extraData.addAll(extraData); } - /// Returns true if the channel is muted + /// Returns true if the channel is muted. bool get isMuted => _client.state.currentUser?.channelMutes .any((element) => element.channel.cid == cid) == true; - /// Returns true if the channel is muted as a stream - Stream? get isMutedStream => _client.state.currentUserStream + /// Returns true if the channel is muted, as a stream. + Stream get isMutedStream => _client.state.currentUserStream .map((event) => - event!.channelMutes.any((element) => element.channel.cid == cid) == + event?.channelMutes.any((element) => element.channel.cid == cid) == true) .distinct(); - /// True if the channel is a group + /// True if the channel is a group. bool get isGroup => memberCount != 2; - /// True if the channel is distinct + /// True if the channel is distinct. bool get isDistinct => id?.startsWith('!members') == true; - /// Channel configuration + /// Channel configuration. ChannelConfig? get config { _checkInitialized(); - return state?._channelState.channel?.config; + return state!._channelState.channel?.config; } - /// Channel configuration as a stream - Stream? get configStream { + /// Channel configuration as a stream. + Stream get configStream { _checkInitialized(); - return state?.channelStateStream.map((cs) => cs.channel?.config); + return state!.channelStateStream.map((cs) => cs.channel?.config); } - /// Channel user creator + /// Channel user creator. User? get createdBy { _checkInitialized(); - return state?._channelState.channel?.createdBy; + return state!._channelState.channel?.createdBy; } - /// Channel user creator as a stream - Stream? get createdByStream { + /// Channel user creator as a stream. + Stream get createdByStream { _checkInitialized(); - return state?.channelStateStream.map((cs) => cs.channel?.createdBy); + return state!.channelStateStream.map((cs) => cs.channel?.createdBy); } - /// Channel frozen status - bool? get frozen { + /// Channel frozen status. + bool get frozen { _checkInitialized(); - return state?._channelState.channel?.frozen; + return state!._channelState.channel?.frozen == true; } - /// Channel frozen status as a stream - Stream? get frozenStream { + /// Channel frozen status as a stream. + Stream get frozenStream { _checkInitialized(); - return state?.channelStateStream.map((cs) => cs.channel?.frozen); + return state!.channelStateStream.map((cs) => cs.channel?.frozen == true); } - /// Channel creation date + /// Cooldown count + int? get cooldown { + _checkInitialized(); + return state?._channelState.channel?.cooldown; + } + + /// Cooldown count as a stream + Stream? get cooldownStream { + _checkInitialized(); + return state?.channelStateStream.map((cs) => cs.channel?.cooldown); + } + + /// Stores time at which cooldown was started + DateTime? cooldownStartedAt; + + /// Channel creation date. DateTime? get createdAt { _checkInitialized(); - return state?._channelState.channel?.createdAt; + return state!._channelState.channel?.createdAt; } - /// Channel creation date as a stream - Stream? get createdAtStream { + /// Channel creation date as a stream. + Stream get createdAtStream { _checkInitialized(); - return state?.channelStateStream.map((cs) => cs.channel?.createdAt); + return state!.channelStateStream.map((cs) => cs.channel?.createdAt); } - /// Channel last message date + /// Channel last message date. DateTime? get lastMessageAt { _checkInitialized(); - - return state?._channelState.channel?.lastMessageAt; + return state!._channelState.channel?.lastMessageAt; } - /// Channel last message date as a stream - Stream? get lastMessageAtStream { + /// Channel last message date as a stream. + Stream get lastMessageAtStream { _checkInitialized(); - - return state?.channelStateStream.map((cs) => cs.channel?.lastMessageAt); + return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt); } - /// Channel updated date + /// Channel updated date. DateTime? get updatedAt { _checkInitialized(); - - return state?._channelState.channel?.updatedAt; + return state!._channelState.channel?.updatedAt; } - /// Channel updated date as a stream - Stream? get updatedAtStream { + /// Channel updated date as a stream. + Stream get updatedAtStream { _checkInitialized(); - - return state?.channelStateStream.map((cs) => cs.channel?.updatedAt); + return state!.channelStateStream.map((cs) => cs.channel?.updatedAt); } - /// Channel deletion date + /// Channel deletion date. DateTime? get deletedAt { _checkInitialized(); - - return state?._channelState.channel?.deletedAt; + return state!._channelState.channel?.deletedAt; } - /// Channel deletion date as a stream - Stream? get deletedAtStream { + /// Channel deletion date as a stream. + Stream get deletedAtStream { _checkInitialized(); - - return state?.channelStateStream.map((cs) => cs.channel?.deletedAt); + return state!.channelStateStream.map((cs) => cs.channel?.deletedAt); } - /// Channel member count + /// Channel member count. int? get memberCount { _checkInitialized(); - - return state?._channelState.channel?.memberCount; + return state!._channelState.channel?.memberCount; } - /// Channel member count as a stream - Stream? get memberCountStream { + /// Channel member count as a stream. + Stream get memberCountStream { _checkInitialized(); - - return state?.channelStateStream.map((cs) => cs.channel?.memberCount); + return state!.channelStateStream.map((cs) => cs.channel?.memberCount); } - /// Channel id + /// Channel id. String? get id => state?._channelState.channel?.id ?? _id; - /// Channel type + /// Channel type. String get type => state?._channelState.channel?.type ?? _type; - /// Channel cid + /// Channel cid. String? get cid => state?._channelState.channel?.cid ?? _cid; - /// Channel team + /// Channel team. String? get team { _checkInitialized(); - return state?._channelState.channel?.team; + return state!._channelState.channel?.team; } - /// Channel extra data + /// Channel extra data. Map get extraData { var data = state?._channelState.channel?.extraData; if (data == null || data.isEmpty) { @@ -210,23 +300,54 @@ class Channel { return data; } - /// Channel extra data as a stream - Stream> get extraDataStream { + /// Channel extra data as a stream. + Stream> get extraDataStream { _checkInitialized(); return state!.channelStateStream.map( (cs) => cs.channel?.extraData ?? _extraData, ); } - /// The main Stream chat client + /// Shortcut to get channel name. + /// + /// {@macro name} + String? get name => extraData['name'] as String?; + + /// Channel [name] as a stream. + /// + /// The channel needs to be initialized. + /// + /// {@macro name} + Stream get nameStream { + _checkInitialized(); + return extraDataStream.map((it) => it['name'] as String?); + } + + /// Shortcut to get channel image. + /// + /// {@macro image} + String? get image => extraData['image'] as String?; + + /// Channel [image] as a stream. + /// + /// The channel needs to be initialized. + /// + /// {@macro image} + Stream get imageStream { + _checkInitialized(); + return extraDataStream.map((it) => it['image'] as String?); + } + + /// The main Stream chat client. StreamChatClient get client => _client; final StreamChatClient _client; final Completer _initializedCompleter = Completer(); - /// True if this is initialized + /// True if this is initialized. + /// /// Call [watch] to initialize the client or instantiate it using - /// [Channel.fromState] + /// [Channel.fromState]. Future get initialized => _initializedCompleter.future; final _cancelableAttachmentUploadRequest = {}; @@ -362,7 +483,9 @@ class Channel { } /// Send a [message] to this channel. - /// If [skipPush] is true the message will not send a push notification + /// + /// If [skipPush] is true the message will not send a push notification. + /// /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually sending the message. Future sendMessage( @@ -417,6 +540,9 @@ class Channel { skipPush: skipPush, ); state!.addMessage(response.message); + if (cooldown! > 0) { + cooldownStartedAt = DateTime.now(); + } return response; } catch (e) { if (e is StreamChatNetworkError && e.isRetriable) { @@ -427,6 +553,7 @@ class Channel { } /// Updates the [message] in this channel. + /// /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. Future updateMessage(Message message) async { @@ -489,8 +616,10 @@ class Channel { } /// Partially updates the [message] in this channel. - /// Use [set] to define values to be set - /// Use [unset] to define values to be unset + /// + /// Use [set] to define values to be set. + /// + /// Use [unset] to define values to be unset. Future partialUpdateMessage( Message message, { Map? set, @@ -590,7 +719,7 @@ class Channel { ); } - /// Unpins provided message + /// Unpins provided message. Future unpinMessage(Message message) => partialUpdateMessage( message, @@ -599,7 +728,7 @@ class Channel { }, ); - /// Send a file to this channel + /// Send a file to this channel. Future sendFile( AttachmentFile file, { ProgressCallback? onSendProgress, @@ -615,7 +744,7 @@ class Channel { ); } - /// Send an image to this channel + /// Send an image to this channel. Future sendImage( AttachmentFile file, { ProgressCallback? onSendProgress, @@ -631,7 +760,7 @@ class Channel { ); } - /// A message search. + /// Search for a message with the given options. Future search({ String? query, Filter? messageFilters, @@ -648,7 +777,7 @@ class Channel { ); } - /// Delete a file from this channel + /// Delete a file from this channel. Future deleteFile( String url, { CancelToken? cancelToken, @@ -662,7 +791,7 @@ class Channel { ); } - /// Delete an image from this channel + /// Delete an image from this channel. Future deleteImage( String url, { CancelToken? cancelToken, @@ -676,14 +805,15 @@ class Channel { ); } - /// Send an event on this channel + /// Send an event on this channel. Future sendEvent(Event event) { _checkInitialized(); return _client.sendEvent(id!, type, event); } - /// Send a reaction to this channel - /// Set [enforceUnique] to true to remove the existing user reaction + /// Send a reaction to this channel. + /// + /// Set [enforceUnique] to true to remove the existing user reaction. Future sendReaction( Message message, String type, { @@ -746,7 +876,7 @@ class Channel { } } - /// Delete a reaction from this channel + /// Delete a reaction from this channel. Future deleteReaction( Message message, Reaction reaction) async { final type = reaction.type; @@ -792,7 +922,49 @@ class Channel { } } - /// Edit the channel custom data + /// Update the channel's [name]. + /// + /// This is the same as calling [updatePartial] and providing a map with a + /// 'name' key: + /// + /// ```dart + /// channel.updatePartial( + /// set: {'name': 'Updated channel name'} + /// ); + /// ``` + /// + /// Instead do: + /// ```dart + /// channel.updateName('Updated channel name'); + /// ``` + Future updateName(String name) => + updatePartial(set: {'name': name}); + + /// Update the channel's [image]. + /// + /// This is the same as calling [updatePartial] and providing a map with an + /// 'image' key: + /// + /// ```dart + /// channel.updatePartial( + /// set: {'image': 'https://getstream.io/new-image'} + /// ); + /// ``` + /// + /// Instead do: + /// ```dart + /// channel.updateImage('https://getstream.io/new-image'); + /// ``` + Future updateImage(String image) => + updatePartial(set: {'image': image}); + + /// Update the channel custom data. This replaces all of the channel data + /// with the given [channelData]. + /// + /// If you instead want to do a partial update, use [updatePartial]. + /// + /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart + /// for more information. Future update( Map channelData, [ Message? updateMessage, @@ -806,7 +978,18 @@ class Channel { ); } - /// Edit the channel custom data + /// A partial update can be used to set and unset specific custom data fields + /// when it is necessary to retain additional custom data fields on the + /// object. + /// + /// - [set] will add, or update existing attributes. + /// - [unset] will remove the attributes with the provided list of + /// values (keys). + /// + /// If you want to do a full update/replacement, use [update] instead. + /// + /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart + /// for more information. Future updatePartial({ Map? set, List? unset, @@ -815,31 +998,45 @@ class Channel { return _client.updateChannelPartial(id!, type, set: set, unset: unset); } + /// Enable slow mode + Future enableSlowMode({ + required int cooldownInterval, + }) async { + _checkInitialized(); + return _client.enableSlowdown(id!, type, cooldownInterval); + } + + /// Disable slow mode + Future disableSlowMode() async { + _checkInitialized(); + return _client.disableSlowdown(id!, type); + } + /// Delete this channel. Messages are permanently removed. Future delete() async { _checkInitialized(); return _client.deleteChannel(id!, type); } - /// Removes all messages from the channel + /// Removes all messages from the channel. Future truncate() async { _checkInitialized(); return _client.truncateChannel(id!, type); } - /// Accept invitation to the channel + /// Accept invitation to the channel. Future acceptInvite([Message? message]) async { _checkInitialized(); return _client.acceptChannelInvite(id!, type, message: message); } - /// Reject invitation to the channel + /// Reject invitation to the channel. Future rejectInvite([Message? message]) async { _checkInitialized(); return _client.rejectChannelInvite(id!, type, message: message); } - /// Add members to the channel + /// Add members to the channel. Future addMembers( List memberIds, [ Message? message, @@ -848,7 +1045,7 @@ class Channel { return _client.addChannelMembers(id!, type, memberIds, message: message); } - /// Invite members to the channel + /// Invite members to the channel. Future inviteMembers( List memberIds, [ Message? message, @@ -857,7 +1054,7 @@ class Channel { return _client.inviteChannelMembers(id!, type, memberIds, message: message); } - /// Remove members from the channel + /// Remove members from the channel. Future removeMembers( List memberIds, [ Message? message, @@ -866,7 +1063,7 @@ class Channel { return _client.removeChannelMembers(id!, type, memberIds, message: message); } - /// Send action for a specific message of this channel + /// Send action for a specific message of this channel. Future sendAction( Message message, Map formData, @@ -913,9 +1110,10 @@ class Channel { return res; } - /// Mark all messages as read + /// Mark all messages as read. + /// /// Optionally provide a [messageId] if you want to mark a - /// particular message as read + /// particular message as read. Future markRead({String? messageId}) async { _checkInitialized(); client.state.totalUnreadCount = @@ -924,7 +1122,7 @@ class Channel { return _client.markChannelRead(id!, type, messageId: messageId); } - /// Loads the initial channel state and watches for changes + /// Loads the initial channel state and watches for changes. Future watch() async { ChannelState response; @@ -955,15 +1153,16 @@ class Channel { } } - /// Stop watching the channel + /// Stop watching the channel. Future stopWatching() async { _checkInitialized(); return _client.stopChannelWatching(id!, type); } - /// List the message replies for a parent message + /// List the message replies for a parent message. + /// /// Set [preferOffline] to true to avoid the api call if the data is already - /// in the offline storage + /// in the offline storage. Future getReplies( String parentId, { PaginationParams? options, @@ -987,7 +1186,7 @@ class Channel { return repliesResponse; } - /// List the reactions for a message in the channel + /// List the reactions for a message in the channel. Future getReactions( String messageId, { PaginationParams? pagination, @@ -997,7 +1196,7 @@ class Channel { pagination: pagination, ); - /// Retrieves a list of messages by ID + /// Retrieves a list of messages by given [messageIDs]. Future getMessagesById( List messageIDs, ) async { @@ -1008,7 +1207,7 @@ class Channel { return res; } - /// Retrieves a list of messages by ID + /// Translate a message by given [messageId] and [language]. Future translateMessage( String messageId, String language, @@ -1018,12 +1217,13 @@ class Channel { language, ); - /// Creates a new channel + /// Creates a new channel. Future create() async => query(state: false); - /// Query the API, get messages, members or other channel fields - /// Set [preferOffline] to true to avoid the api call if the data is already - /// in the offline storage + /// Query the API, get messages, members or other channel fields. + /// + /// Set [preferOffline] to true to avoid the API call if the data is already + /// in the offline storage. Future query({ bool state = true, bool watch = false, @@ -1077,7 +1277,7 @@ class Channel { } } - /// Query channel members + /// Query channel members. Future queryMembers({ Filter? filter, List? sort, @@ -1092,19 +1292,19 @@ class Channel { pagination: pagination, ); - /// Mutes the channel + /// Mutes the channel. Future mute({Duration? expiration}) { _checkInitialized(); return _client.muteChannel(cid!, expiration: expiration); } - /// Unmutes the channel + /// Unmute the channel. Future unmute() { _checkInitialized(); return _client.unmuteChannel(cid!); } - /// Bans a user from the channel + /// Bans the user with given [userID] from the channel. Future banUser( String userID, Map options, @@ -1118,7 +1318,7 @@ class Channel { return _client.banUser(userID, opts); } - /// Remove the ban for a user in the channel + /// Remove the ban for the user with given [userID] in the channel. Future unbanUser(String userID) async { _checkInitialized(); return _client.unbanUser(userID, { @@ -1127,7 +1327,7 @@ class Channel { }); } - /// Shadow bans a user from the channel + /// Shadow bans the user with the given [userID] from the channel. Future shadowBan( String userID, Map options, @@ -1141,7 +1341,7 @@ class Channel { return _client.shadowBan(userID, opts); } - /// Remove the shadow ban for a user in the channel + /// Remove the shadow ban for the user with the given [userID] in the channel. Future removeShadowBan(String userID) async { _checkInitialized(); return _client.removeShadowBan(userID, { @@ -1151,8 +1351,10 @@ class Channel { } /// Hides the channel from [StreamChatClient.queryChannels] for the user - /// until a message is added If [clearHistory] is set to true - all messages - /// will be removed for the user + /// until a message is added. + /// + /// If [clearHistory] is set to true - all messages + /// will be removed for the user. Future hide({bool clearHistory = false}) async { _checkInitialized(); final response = await _client.hideChannel( @@ -1170,7 +1372,7 @@ class Channel { return response; } - /// Removes the hidden status for the channel + /// Removes the hidden status for the channel. Future show() async { _checkInitialized(); return _client.showChannel(id!, type); @@ -1178,7 +1380,7 @@ class Channel { /// Stream of [Event] coming from websocket connection specific for the /// channel. Pass an eventType as parameter in order to filter just a type - /// of event + /// of event. Stream on([ String? eventType, String? eventType2, @@ -1216,7 +1418,7 @@ class Channel { } } - /// Sets last typing to null and sends the typing.stop event + /// Sets last typing to null and sends the typing.stop event. Future stopTyping([String? parentId]) async { if (config?.typingEvents == false) { return; @@ -1230,7 +1432,7 @@ class Channel { )); } - /// Call this method to dispose the channel client + /// Call this method to dispose the channel client. void dispose() { state?.dispose(); } @@ -1244,9 +1446,9 @@ class Channel { } } -/// The class that handles the state of the channel listening to the events +/// The class that handles the state of the channel listening to the events. class ChannelClientState { - /// Creates a new instance listening to events and updating the state + /// Creates a new instance listening to events and updating the state. ChannelClientState( this._channel, ChannelState channelState, @@ -1393,23 +1595,25 @@ class ChannelClientState { } /// Flag which indicates if [ChannelClientState] contain latest/recent messages or not. + /// /// This flag should be managed by UI sdks. - /// When false, any new message (received by WebSocket event - /// - [EventType.messageNew]) will not be pushed on to message list. + /// + /// When false, any new message received by WebSocket event + /// [EventType.messageNew] will not be pushed on to message list. bool get isUpToDate => _isUpToDateController.value; set isUpToDate(bool isUpToDate) => _isUpToDateController.add(isUpToDate); - /// [isUpToDate] flag count as a stream + /// [isUpToDate] flag count as a stream. Stream get isUpToDateStream => _isUpToDateController.stream; final BehaviorSubject _isUpToDateController = BehaviorSubject.seeded(true); - /// The retry queue associated to this channel + /// The retry queue associated to this channel. late final RetryQueue _retryQueue; - /// Retry failed message + /// Retry failed message. Future retryFailedMessages() async { final failedMessages = [...messages, ...threads.values.expand((v) => v)] @@ -1502,7 +1706,7 @@ class ChannelClientState { })); } - /// Add a message to this channel + /// Add a message to this channel. void addMessage(Message message) { if (message.parentId == null || message.showInChannel == true) { final newMessages = List.from(_channelState.messages); @@ -1548,9 +1752,9 @@ class ChannelClientState { (event) { final readList = List.from(_channelState.read); final userReadIndex = - read?.indexWhere((r) => r.user.id == event.user!.id); + read.indexWhere((r) => r.user.id == event.user!.id); - if (userReadIndex != null && userReadIndex != -1) { + if (userReadIndex != -1) { final userRead = readList.removeAt(userReadIndex); if (userRead.user.id == _channel._client.state.currentUser!.id) { unreadCount = 0; @@ -1567,36 +1771,36 @@ class ChannelClientState { ); } - /// Channel message list + /// Channel message list. List get messages => _channelState.messages; - /// Channel message list as a stream - Stream?> get messagesStream => channelStateStream + /// Channel message list as a stream. + Stream> get messagesStream => channelStateStream .map((cs) => cs.messages) .distinct(const ListEquality().equals); - /// Channel pinned message list - List? get pinnedMessages => _channelState.pinnedMessages.toList(); + /// Channel pinned message list. + List get pinnedMessages => _channelState.pinnedMessages.toList(); - /// Channel pinned message list as a stream - Stream?> get pinnedMessagesStream => + /// Channel pinned message list as a stream. + Stream> get pinnedMessagesStream => channelStateStream.map((cs) => cs.pinnedMessages.toList()); - /// Get channel last message + /// Get channel last message. Message? get lastMessage => _channelState.messages.isNotEmpty == true ? _channelState.messages.last : null; - /// Get channel last message - Stream get lastMessageStream => messagesStream - .map((event) => event?.isNotEmpty == true ? event!.last : null); + /// Get channel last message. + Stream get lastMessageStream => + messagesStream.map((event) => event.isNotEmpty ? event.last : null); - /// Channel members list + /// Channel members list. List get members => _channelState.members .map((e) => e.copyWith(user: _channel.client.state.users[e.user!.id])) .toList(); - /// Channel members list as a stream + /// Channel members list as a stream. Stream> get membersStream => CombineLatestStream.combine2< List?, Map, List>( channelStateStream.map((cs) => cs.members), @@ -1605,19 +1809,19 @@ class ChannelClientState { members!.map((e) => e!.copyWith(user: users[e.user!.id])).toList(), ).distinct(const ListEquality().equals); - /// Channel watcher count + /// Channel watcher count. int? get watcherCount => _channelState.watcherCount; - /// Channel watcher count as a stream + /// Channel watcher count as a stream. Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount); - /// Channel watchers list + /// Channel watchers list. List get watchers => _channelState.watchers .map((e) => _channel.client.state.users[e.id] ?? e) .toList(); - /// Channel watchers list as a stream + /// Channel watchers list as a stream. Stream> get watchersStream => CombineLatestStream.combine2< List?, Map, List>( channelStateStream.map((cs) => cs.watchers), @@ -1625,20 +1829,20 @@ class ChannelClientState { (watchers, users) => watchers!.map((e) => users[e.id] ?? e).toList(), ); - /// Channel read list - List? get read => _channelState.read; + /// Channel read list. + List get read => _channelState.read; - /// Channel read list as a stream - Stream?> get readStream => channelStateStream.map((cs) => cs.read); + /// Channel read list as a stream. + Stream> get readStream => channelStateStream.map((cs) => cs.read); final BehaviorSubject _unreadCountController = BehaviorSubject.seeded(0); set unreadCount(int value) => _unreadCountController.add(value); - /// Unread count getter as a stream + /// Unread count getter as a stream. Stream get unreadCountStream => _unreadCountController.stream.distinct(); - /// Unread count getter + /// Unread count getter. int get unreadCount => _unreadCountController.value; bool _countMessageAsUnread(Message message) { @@ -1654,7 +1858,7 @@ class ChannelClientState { !userIsMuted; } - /// Update threads with updated information about messages + /// Update threads with updated information about messages. void updateThreadInfo(String parentId, List messages) { final newThreads = Map>.from(threads); @@ -1676,7 +1880,7 @@ class ChannelClientState { _threads = newThreads; } - /// Delete all channel messages + /// Delete all channel messages. void truncate() { _channelState = _channelState.copyWith( messages: [], @@ -1685,7 +1889,7 @@ class ChannelClientState { final List _updatedMessagesIds = []; - /// Update channelState with updated information + /// Update channelState with updated information. void updateChannelState(ChannelState updatedState) { final newMessages = [ ...updatedState.messages, @@ -1737,13 +1941,13 @@ class ChannelClientState { int _sortByCreatedAt(Message a, Message b) => a.createdAt.compareTo(b.createdAt); - /// The channel state related to this client + /// The channel state related to this client. ChannelState get _channelState => _channelStateController.value; - /// The channel state related to this client as a stream + /// The channel state related to this client as a stream. Stream get channelStateStream => _channelStateController.stream; - /// The channel state related to this client + /// The channel state related to this client. ChannelState get channelState => _channelStateController.value; late BehaviorSubject _channelStateController; @@ -1754,11 +1958,11 @@ class ChannelClientState { _debouncedUpdatePersistenceChannelState.call([v]); } - /// The channel threads related to this channel + /// The channel threads related to this channel. Map> get threads => _threadsController.value.map((key, value) => MapEntry(key, value)); - /// The channel threads related to this channel as a stream + /// The channel threads related to this channel as a stream. Stream>> get threadsStream => _threadsController.stream; final BehaviorSubject>> _threadsController = @@ -1772,10 +1976,10 @@ class ChannelClientState { _threadsController.add(v); } - /// Channel related typing users last value + /// Channel related typing users last value. Map get typingEvents => _typingEventsController.value; - /// Channel related typing users stream + /// Channel related typing users stream. Stream> get typingEventsStream => _typingEventsController.stream; @@ -1880,7 +2084,7 @@ class ChannelClientState { .toList(); updateChannelState(_channelState.copyWith( - pinnedMessages: pinnedMessages!.where(_pinIsValid()).toList(), + pinnedMessages: pinnedMessages.where(_pinIsValid()).toList(), messages: expiredMessages, )); } @@ -1903,7 +2107,7 @@ class ChannelClientState { }); } - /// Call this method to dispose this object + /// Call this method to dispose this object. void dispose() { _debouncedUpdatePersistenceChannelState.cancel(); _unreadCountController.close(); diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 104b23fc..f9d10a92 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -767,7 +767,9 @@ class StreamChatClient { cancelToken: cancelToken, ); - /// Replaces the [channelId] of type [ChannelType] data with [data] + /// Replaces the [channelId] of type [ChannelType] data with [data]. + /// + /// Use [updateChannelPartial] for a partial update. Future updateChannel( String channelId, String channelType, @@ -781,7 +783,10 @@ class StreamChatClient { message: message, ); - /// Updates the [channelId] of type [ChannelType] data with [data] + /// Partial update for the [channelId] of type [ChannelType]. Sets the + /// data provided in [set], and removes the attributes given in [unset]. + /// + /// Use [updateChannel] for a full update. Future updateChannelPartial( String channelId, String channelType, { @@ -1247,6 +1252,28 @@ class StreamChatClient { language, ); + /// Enables slow mode + Future enableSlowdown( + String channelId, + String channelType, + int cooldown, + ) async => + _chatApi.channel.enableSlowdown( + channelId, + channelType, + cooldown, + ); + + /// Disables slow mode + Future disableSlowdown( + String channelId, + String channelType, + ) async => + _chatApi.channel.disableSlowdown( + channelId, + channelType, + ); + /// Pins provided message /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds /// to be added to [DateTime.now] 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 c04f9683..68f0d1c3 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -123,6 +123,35 @@ class ChannelApi { return PartialUpdateChannelResponse.fromJson(response.data); } + /// Enable slowdown + Future enableSlowdown( + String channelId, + String channelType, + int cooldown, + ) async { + final response = await updateChannelPartial( + channelId, + channelType, + set: { + 'cooldown': cooldown, + }, + ); + return response; + } + + /// Disable slowdown + Future disableSlowdown( + String channelId, + String channelType, + ) async { + final response = await updateChannelPartial( + channelId, + channelType, + unset: ['cooldown'], + ); + return response; + } + /// Accept invitation to the channel Future acceptChannelInvite( String channelId, 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 37e587ff..a4e6db79 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -23,6 +23,7 @@ class ChannelModel { this.memberCount = 0, this.extraData = const {}, this.team, + this.cooldown = 0, }) : assert( (cid != null && cid.contains(':')) || (id != null && type != null), 'provide either a cid or an id and type', @@ -81,6 +82,10 @@ class ChannelModel { @JsonKey(includeIfNull: false, toJson: Serializer.readOnly, defaultValue: 0) final int memberCount; + /// The number of seconds in a cooldown + @JsonKey(includeIfNull: false, defaultValue: 0) + final int cooldown; + /// Map of custom channel extraData @JsonKey( includeIfNull: false, @@ -107,6 +112,7 @@ class ChannelModel { 'deleted_at', 'member_count', 'team', + 'cooldown', ]; /// Shortcut for channel name @@ -133,6 +139,7 @@ class ChannelModel { int? memberCount, Map? extraData, String? team, + int? cooldown, }) => ChannelModel( id: id ?? this.id, @@ -148,6 +155,7 @@ class ChannelModel { memberCount: memberCount ?? this.memberCount, extraData: extraData ?? this.extraData, team: team ?? this.team, + cooldown: cooldown ?? this.cooldown, ); /// Returns a new [ChannelModel] that is a combination of this channelModel @@ -168,6 +176,7 @@ class ChannelModel { memberCount: other.memberCount, 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 4bde3d4a..d9adf94b 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,7 @@ ChannelModel _$ChannelModelFromJson(Map json) { memberCount: json['member_count'] as int? ?? 0, extraData: json['extra_data'] as Map? ?? {}, team: json['team'] as String?, + cooldown: json['cooldown'] as int? ?? 0, ); } @@ -57,6 +58,7 @@ Map _$ChannelModelToJson(ChannelModel instance) { writeNotNull('updated_at', readonly(instance.updatedAt)); writeNotNull('deleted_at', readonly(instance.deletedAt)); writeNotNull('member_count', readonly(instance.memberCount)); + val['cooldown'] = instance.cooldown; val['extra_data'] = instance.extraData; writeNotNull('team', readonly(instance.team)); return val; diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 26831555..9250176d 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -184,6 +184,8 @@ class EventChannel extends ChannelModel { DateTime? deletedAt, required int memberCount, Map? extraData, + required int cooldown, + String? team, }) : super( id: id, type: type, @@ -197,6 +199,8 @@ class EventChannel extends ChannelModel { deletedAt: deletedAt, memberCount: memberCount, extraData: extraData ?? {}, + cooldown: cooldown, + team: team, ); /// Create a new instance from a json 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 5247af17..93d2e75b 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -87,5 +87,7 @@ EventChannel _$EventChannelFromJson(Map json) { : DateTime.parse(json['deleted_at'] as String), memberCount: json['member_count'] as int? ?? 0, extraData: json['extra_data'] as Map? ?? {}, + cooldown: json['cooldown'] as int? ?? 0, + team: json['team'] as String?, ); } diff --git a/packages/stream_chat/test/fixtures/channel_state_to_json.json b/packages/stream_chat/test/fixtures/channel_state_to_json.json index 4b8a0369..ebb74fb6 100644 --- a/packages/stream_chat/test/fixtures/channel_state_to_json.json +++ b/packages/stream_chat/test/fixtures/channel_state_to_json.json @@ -4,6 +4,7 @@ "id": "dev", "type": "team", "frozen": true, + "cooldown": 0, "name": "#dev", "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", "example": 1 diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart similarity index 93% rename from packages/stream_chat/test/src/api/channel_test.dart rename to packages/stream_chat/test/src/client/channel_test.dart index 5be55491..08ebd091 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -72,6 +72,40 @@ void main() { expect(channel.extraData.containsKey('name'), isTrue); expect(channel.extraData['name'], 'test-channel-name'); }); + + test('should be able to get and set `image`', () { + expect(channel.extraData.isEmpty, isTrue); + + const imageUrl = 'https://getstream.io/some-image'; + channel.image = imageUrl; + + expect(channel.image, imageUrl); + expect(channel.extraData['image'], imageUrl); + + const newImage = 'https://getstream.io/new-image'; + final newChannelInstance = + Channel(client, channelType, channelId, image: newImage); + + expect(newChannelInstance.image, newImage); + expect(newChannelInstance.extraData['image'], newImage); + }); + + test('should be able to get and set `name`', () { + expect(channel.extraData.isEmpty, isTrue); + + const name = 'Channel name'; + channel.name = name; + + expect(channel.name, name); + expect(channel.extraData['name'], name); + + const newName = 'New channel name'; + final newChannelInstance = + Channel(client, channelType, channelId, name: newName); + + expect(newChannelInstance.name, newName); + expect(newChannelInstance.extraData['name'], newName); + }); }); // TODO : test all persistence related logic in this group @@ -192,6 +226,22 @@ void main() { } }); + test('should throw if trying to set `image`', () { + try { + channel.image = 'https://stream.io/some-image'; + } catch (e) { + expect(e, isA()); + } + }); + + test('should throw if trying to set `name`', () { + try { + channel.name = 'New name'; + } catch (e) { + expect(e, isA()); + } + }); + group('`.sendMessage`', () { test('should work fine', () async { final message = Message(id: 'test-message-id'); @@ -1192,6 +1242,62 @@ void main() { message: any(named: 'message'))).called(1); }); + test('`.updateImage`', () async { + const image = 'https://getstream.io/new-image'; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: {'image': image}, + ); + + when(() => client.updateChannelPartial( + channelId, + channelType, + set: {'image': image}, + )).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updateImage(image); + + expect(res, isNotNull); + expect(res.channel.extraData['image'], image); + + verify(() => client.updateChannelPartial( + channelId, + channelType, + set: {'image': image}, + )).called(1); + }); + + test('`.updateName`', () async { + const name = 'Name'; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: {'name': name}, + ); + + when(() => client.updateChannelPartial( + channelId, + channelType, + set: {'name': name}, + )).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updateName(name); + + expect(res, isNotNull); + expect(res.channel.extraData['name'], name); + + verify(() => client.updateChannelPartial( + channelId, + channelType, + set: {'name': name}, + )).called(1); + }); + test('`.updatePartial`', () async { const set = { 'name': 'Stream Team', @@ -1753,6 +1859,50 @@ void main() { ).called(1); }); + test('`.enableSlowMode`', () async { + const cooldown = 10; + + final channelModel = ChannelModel( + cid: channelCid, + cooldown: cooldown, + ); + + when(() => client.enableSlowdown( + channelId, + channelType, + cooldown, + )).thenAnswer((_) async => PartialUpdateChannelResponse() + ..channel = channelModel); + + final res = await channel.enableSlowMode(cooldownInterval: 10); + + expect(res, isNotNull); + + verify(() => client.enableSlowdown( + channelId, + channelType, + cooldown, + )).called(1); + }); + + test('`.disableSlowMode`', () async { + final channelModel = ChannelModel( + cid: channelCid, + ); + + when(() => client.disableSlowdown( + channelId, + channelType, + )).thenAnswer((_) async => PartialUpdateChannelResponse() + ..channel = channelModel); + + final res = await channel.disableSlowMode(); + + expect(res, isNotNull); + + verify(() => client.disableSlowdown(channelId, channelType)).called(1); + }); + test('`.banUser`', () async { const userId = 'test-user-id'; const options = {'key': 'value'}; diff --git a/packages/stream_chat/test/src/api/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart similarity index 100% rename from packages/stream_chat/test/src/api/client_test.dart rename to packages/stream_chat/test/src/client/client_test.dart diff --git a/packages/stream_chat/test/src/api/retry_queue_test.dart b/packages/stream_chat/test/src/client/retry_queue_test.dart similarity index 100% rename from packages/stream_chat/test/src/api/retry_queue_test.dart rename to packages/stream_chat/test/src/client/retry_queue_test.dart diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index 8ffd778d..9110845d 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -605,4 +605,65 @@ void main() { verify(() => client.post(path, data: {})).called(1); verifyNoMoreInteractions(client); }); + + test('enableSlowdown', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const cooldown = 10; + const set = { + 'cooldown': 10, + }; + + final path = _getChannelUrl(channelId, channelType); + + final channelModel = ChannelModel( + id: channelId, + type: channelType, + extraData: set, + ); + + when(() => client.patch(path, data: { + 'set': set, + })).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + })); + + final res = + await channelApi.enableSlowdown(channelId, channelType, cooldown); + + expect(res, isNotNull); + + verify(() => client.patch(path, data: { + 'set': set, + })).called(1); + verifyNoMoreInteractions(client); + }); + + test('disableSlowdown', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const unset = ['cooldown']; + + final path = _getChannelUrl(channelId, channelType); + + final channelModel = ChannelModel( + id: channelId, + type: channelType, + ); + + when(() => client.patch(path, data: { + 'unset': unset, + })).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + })); + + final res = await channelApi.disableSlowdown(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.patch(path, data: { + 'unset': unset, + })).called(1); + verifyNoMoreInteractions(client); + }); } diff --git a/packages/stream_chat/test/src/core/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart index ca734c44..e27f5970 100644 --- a/packages/stream_chat/test/src/core/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -12,6 +12,7 @@ void main() { expect(channel.cid, equals('livestream:test')); expect(channel.extraData['cats'], equals(true)); expect(channel.extraData['fruit'], equals(['bananas', 'apples'])); + expect(channel.cooldown, equals(0)); }); test('should serialize to json correctly', () { @@ -24,7 +25,13 @@ void main() { expect( channel.toJson(), - {'id': 'id', 'type': 'type', 'frozen': false, 'name': 'cool'}, + { + 'id': 'id', + 'type': 'type', + 'frozen': false, + 'cooldown': 0, + 'name': 'cool', + }, ); }); @@ -38,7 +45,13 @@ void main() { expect( channel.toJson(), - {'id': 'id', 'type': 'type', 'name': 'cool', 'frozen': false}, + { + 'id': 'id', + 'type': 'type', + 'frozen': false, + 'cooldown': 0, + 'name': 'cool', + }, ); }); }); diff --git a/packages/stream_chat/test/src/core/models/reaction_test.dart b/packages/stream_chat/test/src/core/models/reaction_test.dart index 0891b548..fbe493ef 100644 --- a/packages/stream_chat/test/src/core/models/reaction_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -13,10 +13,11 @@ void main() { expect(reaction.type, 'wow'); expect( reaction.user?.toJson(), - User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const { - 'image': 'https://randomuser.me/api/portraits/women/45.jpg', - 'name': 'Daisy Morgan' - }).toJson(), + User( + id: '2de0297c-f3f2-489d-b930-ef77342edccf', + image: 'https://randomuser.me/api/portraits/women/45.jpg', + name: 'Daisy Morgan', + ).toJson(), ); expect(reaction.score, 1); expect(reaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); @@ -28,11 +29,11 @@ void main() { messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04', createdAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), type: 'wow', - user: - User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const { - 'image': 'https://randomuser.me/api/portraits/women/45.jpg', - 'name': 'Daisy Morgan' - }), + user: User( + id: '2de0297c-f3f2-489d-b930-ef77342edccf', + image: 'https://randomuser.me/api/portraits/women/45.jpg', + name: 'Daisy Morgan', + ), userId: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: {'bananas': 'yes'}, score: 1, @@ -58,10 +59,11 @@ void main() { expect(newReaction.type, 'wow'); expect( newReaction.user?.toJson(), - User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const { - 'image': 'https://randomuser.me/api/portraits/women/45.jpg', - 'name': 'Daisy Morgan', - }).toJson(), + User( + id: '2de0297c-f3f2-489d-b930-ef77342edccf', + image: 'https://randomuser.me/api/portraits/women/45.jpg', + name: 'Daisy Morgan', + ).toJson(), ); expect(newReaction.score, 1); expect(newReaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 464e40ea..afeaeffa 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -2,8 +2,9 @@ ✅ Added -- [#516](https://github.com/GetStream/stream-chat-flutter/issues/516): Added `StreamChatThemeData.placeholderUserImage` for - building a widget when the `UserAvatar` image is loading +- [#516](https://github.com/GetStream/stream-chat-flutter/issues/516): + Added `StreamChatThemeData.placeholderUserImage` for building a widget when the `UserAvatar` image + is loading - Added a `backgroundColor` property to the following widgets: - `ChannelHeader` - `ChannelListHeader` @@ -13,13 +14,26 @@ - Added `MessageInput.attachmentLimit` in order to limit the no. of attachments that can be sent with a single message. - Added `MessageInput.onAttachmentLimitExceed` callback which will be called when the `attachmentLimit` is exceeded. This will override the default error alert behaviour. +- Added `MessageInput.attachmentButtonBuilder` and `MessageInput.commandButtonBuilder` for more customizations. +```dart +typedef ActionButtonBuilder = Widget Function( + BuildContext context, + IconButton defaultActionButton, + ); +``` + +> **_NOTE:_** The last parameter is the default `ActionButton` +You can call `.copyWith` to customize just a subset of properties. + +- Added slow mode which allows a cooldown period after a user sends a message. 🔄 Changed -Theming has been upgraded! Most theme classes now have `InheritedTheme` classes -associated with them, and have been upgraded with some goodies like `lerp` functions. -Here's the full naming breakdown: +Theming has been upgraded! Most theme classes now have `InheritedTheme` classes associated with +them, and have been upgraded with some goodies like `lerp` functions. Here's the full naming +breakdown: + * `AvatarTheme` is now `AvatarThemeData` * `ChannelHeaderTheme` is now `ChannelHeaderThemeData` * `ChannelListHeaderTheme` is now `ChannelListHeaderThemeData` @@ -31,12 +45,23 @@ Here's the full naming breakdown: * `MessageTheme` is now `MessageThemeData` * `UserListViewTheme` is now `UserListViewThemeData` +- Updated core dependency. + +🐞 Fixed + +- Fixed `MessageInput` textField case where `input` is not enabled if the file picked from the + camera is null. +- Fixed date dividers position/alignment in non reversed `MessageListView`. +- Fixed `MessageListView` not opening to the right initialMessage if `StreamChannel.initialMessageId` is set. +- Fixed null check errors when accessing `message.text` in `MessageWidget` and `MessageListView`; this occurred when sending a message with no text. + ## 2.1.2 🐞 Fixed -- [#590](https://github.com/GetStream/stream-chat-flutter/issues/590): livestream use case, no members when sending message - +- [#590](https://github.com/GetStream/stream-chat-flutter/issues/590): livestream use case, no + members when sending message + ## 2.1.1 - Updated core dependency @@ -53,7 +78,8 @@ Here's the full naming breakdown: 🔄 Changed - `StreamChat.of(context).user` is now deprecated in favor of `StreamChat.of(context).currentUser`. -- `StreamChat.of(context).userStream` is now deprecated in favor of `StreamChat.of(context).currentUserStream`. +- `StreamChat.of(context).userStream` is now deprecated in favor + of `StreamChat.of(context).currentUserStream`. 🐞 Fixed @@ -67,17 +93,17 @@ Here's the full naming breakdown: - Renamed `ChannelImage` to `ChannelAvatar` - Updated `StreamChatThemeData.reactionIcons` to accept custom builder - Renamed `ColorTheme` properties to reflect the purpose of the colors - - `ColorTheme.black` -> `ColorTheme.textHighEmphasis` - - `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` - - `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` - - `ColorTheme.greyWhisper` -> `ColorTheme.borders` - - `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` - - `ColorTheme.whiteSnow` -> `ColorTheme.appBg` - - `ColorTheme.white` -> `ColorTheme.barsBg` - - `ColorTheme.blueAlice` -> `ColorTheme.linkBg` - - `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` - - `ColorTheme.accentRed` -> `ColorTheme.accentError` - - `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` + - `ColorTheme.black` -> `ColorTheme.textHighEmphasis` + - `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` + - `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` + - `ColorTheme.greyWhisper` -> `ColorTheme.borders` + - `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` + - `ColorTheme.whiteSnow` -> `ColorTheme.appBg` + - `ColorTheme.white` -> `ColorTheme.barsBg` + - `ColorTheme.blueAlice` -> `ColorTheme.linkBg` + - `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` + - `ColorTheme.accentRed` -> `ColorTheme.accentError` + - `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` - `ChannelListCore` options property is removed in favor of individual properties - `options.state` -> bool state @@ -98,7 +124,7 @@ typedef MessageBuilder = Widget Function( ); ``` -the last parameter is the default `MessageWidget` +> **_NOTE:_** the last parameter is the default `MessageWidget` You can call `.copyWith` to customize just a subset of properties @@ -106,7 +132,8 @@ You can call `.copyWith` to customize just a subset of properties - Added video compress options (frame and quality) to `MessageInput` - TypingIndicator now has a property called `parentId` to show typing indicator specific to threads -- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView header/footer +- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView + header/footer - `MessageWidget` accepts a `userAvatarBuilder` - Added pinMessage ui support - Added `MessageListView.threadSeparatorBuilder` property @@ -115,10 +142,12 @@ You can call `.copyWith` to customize just a subset of properties 🐞 Fixed -- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text box when editing - message -- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator use case -- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without a reload +- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text + box when editing message +- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator + use case +- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without + a reload - `MessageListView` not rendering if the user is not a member of the channel - Fix `MessageInput` overflow when there are no actions - Minor fixes and improvements @@ -128,17 +157,17 @@ You can call `.copyWith` to customize just a subset of properties 🛑️ Breaking Changes from `2.0.0-nullsafety.8` - Renamed `ColorTheme` properties to reflect the purpose of the colors - - `ColorTheme.black` -> `ColorTheme.textHighEmphasis` - - `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` - - `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` - - `ColorTheme.greyWhisper` -> `ColorTheme.borders` - - `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` - - `ColorTheme.whiteSnow` -> `ColorTheme.appBg` - - `ColorTheme.white` -> `ColorTheme.barsBg` - - `ColorTheme.blueAlice` -> `ColorTheme.linkBg` - - `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` - - `ColorTheme.accentRed` -> `ColorTheme.accentError` - - `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` + - `ColorTheme.black` -> `ColorTheme.textHighEmphasis` + - `ColorTheme.grey` -> `ColorTheme.textLowEmphasis` + - `ColorTheme.greyGainsboro` -> `ColorTheme.disabled` + - `ColorTheme.greyWhisper` -> `ColorTheme.borders` + - `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg` + - `ColorTheme.whiteSnow` -> `ColorTheme.appBg` + - `ColorTheme.white` -> `ColorTheme.barsBg` + - `ColorTheme.blueAlice` -> `ColorTheme.linkBg` + - `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary` + - `ColorTheme.accentRed` -> `ColorTheme.accentError` + - `ColorTheme.accentGreen` -> `ColorTheme.accentInfo` ✅ Added @@ -165,21 +194,24 @@ typedef MessageBuilder = Widget Function( ); ``` -the last parameter is the default `MessageWidget` -You can call `.copyWith` to customize just a subset of properties +> **_NOTE:_** The last parameter is the default `MessageWidget` +You can call `.copyWith` to customize just a subset of properties. ✅ Added - TypingIndicator now has a property called `parentId` to show typing indicator specific to threads -- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView header/footer +- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView + header/footer - `MessageWidget` accepts a `userAvatarBuilder` 🐞 Fixed -- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text box when editing - message -- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator use case -- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without a reload +- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text + box when editing message +- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator + use case +- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without + a reload - `MessageListView` not rendering if the user is not a member of the channel ## 2.0.0-nullsafety.7 @@ -249,7 +281,8 @@ You can call `.copyWith` to customize just a subset of properties - Show error messages as system and keep them in the message input - Remove notification badge logic - Use shimmer while loading images -- Polished `StreamChatTheme` adding more options and a new `MessageInputTheme` dedicated to `MessageInput` +- Polished `StreamChatTheme` adding more options and a new `MessageInputTheme` dedicated + to `MessageInput` - Add possibility to specify custom message actions using `MessageWidget.customActions` - Added `MessageListView.onAttachmentTap` callback - Fixed message newline issue @@ -306,7 +339,8 @@ You can call `.copyWith` to customize just a subset of properties - Improved api documentation - Updated `stream_chat` dependency to `^1.0.0-beta` - Extracted sample app into dedicated [repo](https://github.com/GetStream/flutter-samples) -- Reimplemented existing widgets using [stream_chat_flutter_core](https://pub.dev/packages/stream_chat_flutter_core) +- Reimplemented existing widgets + using [stream_chat_flutter_core](https://pub.dev/packages/stream_chat_flutter_core) ## 0.2.21 @@ -323,8 +357,8 @@ You can call `.copyWith` to customize just a subset of properties ## 0.2.20+2 -- Added `shouldAddChannel` to ChannelsBloc in order to check if a channel has to be added to the list when a new message - arrives +- Added `shouldAddChannel` to ChannelsBloc in order to check if a channel has to be added to the + list when a new message arrives ## 0.2.20+1 @@ -358,7 +392,8 @@ You can call `.copyWith` to customize just a subset of properties ## 0.2.16 -- Do not wrap channel preview builder. Users will have to implement they're custom onTap/onLongPress implementation +- Do not wrap channel preview builder. Users will have to implement they're custom onTap/onLongPress + implementation - Make public autofocus field of the TextField of message_input ## 0.2.15 @@ -543,10 +578,11 @@ You can call `.copyWith` to customize just a subset of properties ## 0.2.1-alpha+1 -- Removed the additional `Navigator` in `StreamChat` widget. It was added to make the app have the `StreamChat` widget - as ancestor in every route. Now the recommended way to add `StreamChat` to your app is using the `builder` property of - your `MaterialApp` widget. Otherwise you can use it in the usual way, but you need to add a `StreamChat` widget to - every route of your app. Read [this issue](https://github.com/GetStream/stream-chat-flutter/issues/47) for more +- Removed the additional `Navigator` in `StreamChat` widget. It was added to make the app have + the `StreamChat` widget as ancestor in every route. Now the recommended way to add `StreamChat` to + your app is using the `builder` property of your `MaterialApp` widget. Otherwise you can use it in + the usual way, but you need to add a `StreamChat` widget to every route of your app. + Read [this issue](https://github.com/GetStream/stream-chat-flutter/issues/47) for more information. ```dart @@ -648,8 +684,8 @@ Widget build(BuildContext context) { - Add gesture (vertical drag down) to close the keyboard -- Add keyboard type parameters (set it to TextInputType.text to show the submit button that will even close the - keyboard) +- Add keyboard type parameters (set it to TextInputType.text to show the submit button that will + even close the keyboard) The property showVideoFullScreen was added mainly because of this issue brianegan/chewie#261 diff --git a/packages/stream_chat_flutter/lib/src/channel_list_view.dart b/packages/stream_chat_flutter/lib/src/channel_list_view.dart index eadbed59..d6cd454b 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -584,11 +584,16 @@ class _ChannelListViewState extends State { ), ], child: widget.channelPreviewBuilder?.call(context, channel) ?? - ChannelPreview( - onLongPress: widget.onChannelLongPress, - channel: channel, - onImageTap: () => widget.onImageTap?.call(channel), - onTap: (channel) => onTap(channel, widget.channelWidget), + DecoratedBox( + decoration: BoxDecoration( + color: chatThemeData.channelListViewTheme.backgroundColor, + ), + child: ChannelPreview( + onLongPress: widget.onChannelLongPress, + channel: channel, + onImageTap: () => widget.onImageTap?.call(channel), + onTap: (channel) => onTap(channel, widget.channelWidget), + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 333ee886..f3984ab9 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -94,13 +94,13 @@ class ChannelPreview extends StatelessWidget { textStyle: channelPreviewTheme.titleStyle, ), ), - BetterStreamBuilder?>( + BetterStreamBuilder>( stream: channel.state?.membersStream, initialData: channel.state?.members, comparator: const ListEquality().equals, builder: (context, members) { - if (members?.isEmpty == true || - members?.any((Member e) => + if (members.isEmpty || + members.any((Member e) => e.user!.id == channel.client.state.currentUser?.id) != true) { @@ -132,7 +132,7 @@ class ChannelPreview extends StatelessWidget { message: lastMessage!, size: channelPreviewTheme.indicatorIconSize, isMessageRead: channel.state!.read - ?.where((element) => + .where((element) => element.user.id != channel .client.state.currentUser!.id) @@ -153,13 +153,10 @@ class ChannelPreview extends StatelessWidget { )); } - Widget _buildDate(BuildContext context) => BetterStreamBuilder( + Widget _buildDate(BuildContext context) => BetterStreamBuilder( stream: channel.lastMessageAtStream, initialData: channel.lastMessageAt, builder: (context, data) { - if (data == null) { - return const Offstage(); - } final lastMessageAt = data.toLocal(); String stringDate; @@ -213,12 +210,12 @@ class ChannelPreview extends StatelessWidget { Widget _buildLastMessage(BuildContext context) => Align( alignment: Alignment.centerLeft, - child: BetterStreamBuilder?>( + child: BetterStreamBuilder>( stream: channel.state!.messagesStream, initialData: channel.state!.messages, builder: (context, data) { - final lastMessage = data - ?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); + final lastMessage = + data.lastWhereOrNull((m) => !m.shadowed && !m.isDeleted); if (lastMessage == null) { return const SizedBox(); } diff --git a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart index fbe7ffae..2cba8eb4 100644 --- a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart +++ b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart @@ -38,7 +38,7 @@ class ConnectionStatusBuilder extends StatelessWidget { return BetterStreamBuilder( initialData: client.wsConnectionStatus, stream: stream, - loadingBuilder: loadingBuilder, + noDataBuilder: loadingBuilder, errorBuilder: (context, error) { if (errorBuilder != null) { return errorBuilder!(context, error); diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index b975941e..19bf4523 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -46,9 +46,9 @@ extension PlatformFileX on PlatformFile { ); } -/// +/// Extension on [InputDecoration] extension InputDecorationX on InputDecoration { - /// + /// Merges this [InputDecoration] with the [other] InputDecoration merge(InputDecoration? other) { if (other == null) return this; return copyWith( @@ -123,3 +123,50 @@ extension FlipBorder on BorderRadius { bottomRight: bottomLeft) : this; } + +/// Extension on [IconButton] +extension IconButtonX on IconButton { + /// Creates a copy of [IconButton] with specified attributes overridden. + IconButton copyWith({ + double? iconSize, + VisualDensity? visualDensity, + EdgeInsetsGeometry? padding, + AlignmentGeometry? alignment, + double? splashRadius, + Color? color, + Color? focusColor, + Color? hoverColor, + Color? highlightColor, + Color? splashColor, + Color? disabledColor, + void Function()? onPressed, + MouseCursor? mouseCursor, + FocusNode? focusNode, + bool? autofocus, + String? tooltip, + bool? enableFeedback, + BoxConstraints? constraints, + Widget? icon, + }) => + IconButton( + iconSize: iconSize ?? this.iconSize, + visualDensity: visualDensity ?? this.visualDensity, + padding: padding ?? this.padding, + alignment: alignment ?? this.alignment, + splashRadius: splashRadius ?? this.splashRadius, + color: color ?? this.color, + focusColor: focusColor ?? this.focusColor, + hoverColor: hoverColor ?? this.hoverColor, + highlightColor: highlightColor ?? this.highlightColor, + splashColor: splashColor ?? this.splashColor, + disabledColor: disabledColor ?? this.disabledColor, + onPressed: onPressed ?? this.onPressed, + mouseCursor: mouseCursor ?? this.mouseCursor, + focusNode: focusNode ?? this.focusNode, + autofocus: autofocus ?? this.autofocus, + tooltip: tooltip ?? this.tooltip, + enableFeedback: enableFeedback ?? this.enableFeedback, + constraints: constraints ?? this.constraints, + icon: icon ?? this.icon, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery_footer.dart index 365368ac..2b2858f8 100644 --- a/packages/stream_chat_flutter/lib/src/gallery_footer.dart +++ b/packages/stream_chat_flutter/lib/src/gallery_footer.dart @@ -142,9 +142,9 @@ class _GalleryFooterState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - '${widget.currentPage + 1} ' - '${context.translations.ofText} ' - '${widget.totalPages}', + context.translations.galleryPaginationText( + currentPage: widget.currentPage, + totalPages: widget.totalPages), style: galleryFooterThemeData.titleTextStyle, ), ], diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index e5dd5df3..cf76ea0b 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -100,6 +100,9 @@ abstract class Translations { /// The label for write a message in [MessageInput] String get writeAMessageLabel; + /// The label for slow mode enabled in [MessageInput] + String get slowModeOnLabel; + /// The label for instant commands in [MessageInput] String get instantCommandsLabel; @@ -296,8 +299,9 @@ abstract class Translations { /// The text shown for "You" String get youText; - /// The text shown for "Of" - String get ofText; + /// Gallery footer pagination text + String galleryPaginationText( + {required int currentPage, required int totalPages}); /// The text shown for "File" String get fileText; @@ -659,7 +663,9 @@ class DefaultTranslations implements Translations { String get youText => 'You'; @override - String get ofText => 'of'; + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '${currentPage + 1} of $totalPages'; @override String get fileText => 'File'; @@ -670,4 +676,7 @@ class DefaultTranslations implements Translations { @override String attachmentLimitExceedError(int limit) => 'Attachment limit exceeded, limit: $limit'; + + @override + String get slowModeOnLabel => 'Slow mode ON'; } diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index bce164f0..f7964c1e 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -60,6 +60,15 @@ typedef MentionTileBuilder = Widget Function( Member member, ); +/// Widget builder for action button. +/// +/// [defaultActionButton] is the default [IconButton] configuration, +/// use [defaultActionButton.copyWith] to easily customize it. +typedef ActionButtonBuilder = Widget Function( + BuildContext context, + IconButton defaultActionButton, +); + /// Location for actions on the [MessageInput] enum ActionsLocation { /// Align to left @@ -176,6 +185,8 @@ class MessageInput extends StatefulWidget { this.onError, this.attachmentLimit = 10, this.onAttachmentLimitExceed, + this.attachmentButtonBuilder, + this.commandButtonBuilder, }) : assert( initialMessage == null || editMessage == null, "Can't provide both `initialMessage` and `editMessage`", @@ -271,6 +282,18 @@ class MessageInput extends StatefulWidget { /// This will override the default error alert behaviour. final AttachmentLimitExceedListener? onAttachmentLimitExceed; + /// Builder for customizing the attachment button. + /// + /// The builder contains the default [IconButton] that can be customized by + /// calling `.copyWith`. + final ActionButtonBuilder? attachmentButtonBuilder; + + /// Builder for customizing the command button. + /// + /// The builder contains the default [IconButton] that can be customized by + /// calling `.copyWith`. + final ActionButtonBuilder? commandButtonBuilder; + @override MessageInputState createState() => MessageInputState(); @@ -316,10 +339,15 @@ class MessageInputState extends State { bool get _hasQuotedMessage => widget.quotedMessage != null; bool get _messageIsPresent => _textEditingController.text.trim().isNotEmpty; + late DateTime? _cooldownStartedAt; + int? _timeOut; + + Timer? _slowModeTimer; @override void initState() { super.initState(); + _startSlowMode(); _focusNode = widget.focusNode ?? FocusNode(); _emojiNames = Emoji.all().where((it) => it.name != null).map((e) => e.name!); @@ -350,6 +378,25 @@ class MessageInputState extends State { }); } + void _startSlowMode() { + final channel = StreamChannel.of(context).channel; + if (channel.cooldownStartedAt != null) { + _cooldownStartedAt = channel.cooldownStartedAt; + if (DateTime.now().difference(_cooldownStartedAt!).inSeconds < + channel.cooldown!) { + _timeOut = channel.cooldown! - + DateTime.now().difference(_cooldownStartedAt!).inSeconds; + _slowModeTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_timeOut == 0) { + timer.cancel(); + } else { + setState(() => _timeOut = _timeOut! - 1); + } + }); + } + } + } + @override Widget build(BuildContext context) { Widget child = DecoratedBox( @@ -428,11 +475,11 @@ class MessageInputState extends State { children: [ if (!_commandEnabled && widget.actionsLocation == ActionsLocation.left) - _buildExpandActionsButton(), + _buildExpandActionsButton(context), _buildTextInput(context), if (!_commandEnabled && widget.actionsLocation == ActionsLocation.right) - _buildExpandActionsButton(), + _buildExpandActionsButton(context), if (widget.sendButtonLocation == SendButtonLocation.outside) _animateSendButton(context), ], @@ -498,24 +545,29 @@ class MessageInputState extends State { ); Widget _animateSendButton(BuildContext context) { - final sendButton = widget.activeSendButton != null - ? InkWell( - onTap: sendMessage, - child: widget.activeSendButton, - ) - : _buildSendButton(context); - return AnimatedCrossFade( - crossFadeState: (_messageIsPresent || _attachments.isNotEmpty) - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: sendButton, - secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), - duration: _messageInputTheme.sendAnimationDuration!, - alignment: Alignment.center, + late Widget sendButton; + if (_timeOut != null && _timeOut! > 0) { + sendButton = _CountdownButton( + count: _timeOut!, + ); + } else if (!_messageIsPresent && _attachments.isEmpty) { + sendButton = widget.idleSendButton ?? _buildIdleSendButton(context); + } else { + sendButton = widget.activeSendButton != null + ? InkWell( + onTap: sendMessage, + child: widget.activeSendButton, + ) + : _buildSendButton(context); + } + + return AnimatedSwitcher( + duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!, + child: sendButton, ); } - Widget _buildExpandActionsButton() { + Widget _buildExpandActionsButton(BuildContext context) { final channel = StreamChannel.of(context).channel; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -553,12 +605,13 @@ class MessageInputState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - if (!widget.disableAttachments) _buildAttachmentButton(), + if (!widget.disableAttachments) + _buildAttachmentButton(context), if (widget.showCommandsButton && widget.editMessage == null && channel.state != null && channel.config?.commands.isNotEmpty == true) - _buildCommandButton(), + _buildCommandButton(context), ...widget.actions ?? [], ].insertBetween(const SizedBox(width: 8)), ), @@ -694,9 +747,7 @@ class MessageInputState extends State { : (widget.actionsLocation == ActionsLocation.leftInside ? Row( mainAxisSize: MainAxisSize.min, - children: [ - _buildExpandActionsButton(), - ], + children: [_buildExpandActionsButton(context)], ) : null), suffixIconConstraints: const BoxConstraints.tightFor(height: 40), @@ -722,7 +773,7 @@ class MessageInputState extends State { ), if (!_commandEnabled && widget.actionsLocation == ActionsLocation.rightInside) - _buildExpandActionsButton(), + _buildExpandActionsButton(context), if (widget.sendButtonLocation == SendButtonLocation.inside) _animateSendButton(context), ], @@ -783,6 +834,10 @@ class MessageInputState extends State { if (_attachments.isNotEmpty) { return context.translations.addACommentOrSendLabel; } + if (_timeOut != 0 && _timeOut != null) { + return context.translations.slowModeOnLabel; + } + return context.translations.writeAMessageLabel; } @@ -1702,10 +1757,9 @@ class MessageInputState extends State { } } - Widget _buildCommandButton() { + Widget _buildCommandButton(BuildContext context) { final s = _textEditingController.text.trim(); - - return IconButton( + final defaultButton = IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty ? _streamChatTheme.colorTheme.disabled @@ -1743,38 +1797,46 @@ class MessageInputState extends State { } }, ); + + return widget.commandButtonBuilder?.call(context, defaultButton) ?? + defaultButton; } - Widget _buildAttachmentButton() => IconButton( - icon: StreamSvgIcon.attach( - color: _openFilePickerSection - ? _messageInputTheme.actionButtonColor - : _messageInputTheme.actionButtonIdleColor, - ), - padding: const EdgeInsets.all(0), - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - splashRadius: 24, - onPressed: () async { - _emojiOverlay?.remove(); - _emojiOverlay = null; - _commandsOverlay?.remove(); - _commandsOverlay = null; - _mentionsOverlay?.remove(); - _mentionsOverlay = null; + Widget _buildAttachmentButton(BuildContext context) { + final defaultButton = IconButton( + icon: StreamSvgIcon.attach( + color: _openFilePickerSection + ? _messageInputTheme.actionButtonColor + : _messageInputTheme.actionButtonIdleColor, + ), + padding: const EdgeInsets.all(0), + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () async { + _emojiOverlay?.remove(); + _emojiOverlay = null; + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; - if (_openFilePickerSection) { - setState(() { - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); - } else { - showAttachmentModal(); - } - }, - ); + if (_openFilePickerSection) { + setState(() { + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + } else { + showAttachmentModal(); + } + }, + ); + + return widget.attachmentButtonBuilder?.call(context, defaultButton) ?? + defaultButton; + } /// Show the attachment modal, making the user choose where to /// pick a media from @@ -1904,15 +1966,14 @@ class MessageInputState extends State { } else if (fileType == DefaultAttachmentTypes.video) { pickedFile = await _imagePicker.pickVideo(source: ImageSource.camera); } - if (pickedFile == null) { - return; + if (pickedFile != null) { + final bytes = await pickedFile.readAsBytes(); + file = AttachmentFile( + size: bytes.length, + path: pickedFile.path, + bytes: bytes, + ); } - final bytes = await pickedFile.readAsBytes(); - file = AttachmentFile( - size: bytes.length, - path: pickedFile.path, - bytes: bytes, - ); } else { late FileType type; if (fileType == DefaultAttachmentTypes.image) { @@ -2119,6 +2180,7 @@ class MessageInputState extends State { if (resp.message?.type == 'error') { _parseExistingMessage(message); } + _startSlowMode(); widget.onMessageSent?.call(resp.message); } catch (e, stk) { if (widget.onError != null) { @@ -2207,6 +2269,7 @@ class MessageInputState extends State { _emojiOverlay?.remove(); _mentionsOverlay?.remove(); _keyboardListener?.cancel(); + _slowModeTimer?.cancel(); super.dispose(); } @@ -2332,3 +2395,30 @@ class _PickerWidgetState extends State<_PickerWidget> { ); } } + +class _CountdownButton extends StatelessWidget { + const _CountdownButton({ + Key? key, + required this.count, + }) : super(key: key); + + final int count; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(8), + child: DecoratedBox( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.disabled, + shape: BoxShape.circle, + ), + child: SizedBox( + height: 24, + width: 24, + child: Center( + child: Text('$count'), + ), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index a3830966..731742b7 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -298,8 +298,9 @@ class _MessageListViewState extends State { StreamChannelState? streamChannel; late StreamChatThemeData _streamTheme; - int? get _initialIndex { - if (widget.initialScrollIndex != null) return widget.initialScrollIndex; + int get _initialIndex { + final initialScrollIndex = widget.initialScrollIndex; + if (initialScrollIndex != null) return initialScrollIndex; if (streamChannel!.initialMessageId != null) { final messages = streamChannel!.channel.state!.messages; final totalMessages = messages.length; @@ -312,8 +313,9 @@ class _MessageListViewState extends State { return 0; } - double? get _initialAlignment { - if (widget.initialAlignment != null) return widget.initialAlignment; + double get _initialAlignment { + final initialAlignment = widget.initialAlignment; + if (initialAlignment != null) return initialAlignment; return 0; } @@ -326,8 +328,8 @@ class _MessageListViewState extends State { bool _topPaginationActive = false; bool _bottomPaginationActive = false; - int? initialIndex; - double? initialAlignment; + int initialIndex = 0; + double initialAlignment = 0; List messages = []; @@ -454,10 +456,12 @@ class _MessageListViewState extends State { _inBetweenList = true; }, child: ScrollablePositionedList.separated( - key: ValueKey(initialIndex! + initialAlignment!), + key: _upToDate + ? null + : ValueKey(initialIndex + initialAlignment), itemPositionsListener: _itemPositionListener, - initialScrollIndex: initialIndex ?? 0, - initialAlignment: initialAlignment ?? 0, + initialScrollIndex: initialIndex, + initialAlignment: initialAlignment, physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: widget.reverse, @@ -505,8 +509,14 @@ class _MessageListViewState extends State { if (i == 1 || i == itemCount - 4) return const Offstage(); - final message = messages[i - 1]; - final nextMessage = messages[i - 2]; + late final Message message, nextMessage; + if (widget.reverse) { + message = messages[i - 1]; + nextMessage = messages[i - 2]; + } else { + message = messages[i - 2]; + nextMessage = messages[i - 1]; + } if (!Jiffy(message.createdAt.toLocal()).isSame( nextMessage.createdAt.toLocal(), Units.DAY, @@ -636,8 +646,7 @@ class _MessageListViewState extends State { } Positioned _buildFloatingDateDivider(int itemCount) => Positioned( - top: widget.reverse ? 20 : null, - bottom: widget.reverse ? null : 20, + top: 20, left: 0, right: 0, child: BetterStreamBuilder>( @@ -647,19 +656,36 @@ class _MessageListViewState extends State { if (a == null || b == null) { return false; } - final aTop = _getTopElementIndex(a); - final bTop = _getTopElementIndex(b); - return aTop == bTop; + if (widget.reverse) { + final aTop = _getTopElementIndex(a); + final bTop = _getTopElementIndex(b); + return aTop == bTop; + } else { + final aBottom = _getBottomElementIndex(a); + final bBottom = _getBottomElementIndex(b); + return aBottom == bBottom; + } }, builder: (context, values) { if (values.isEmpty || messages.isEmpty) { return const Offstage(); } - final index = _getTopElementIndex(values); + int? index; + if (widget.reverse) { + index = _getTopElementIndex(values); + } else { + index = _getBottomElementIndex(values); + } - if (index == null || index <= 2 || index >= itemCount - 3) { - return const Offstage(); + if (index == null) return const Offstage(); + + if (index <= 2 || index >= itemCount - 3) { + if (widget.reverse) { + index = itemCount - 4; + } else { + index = 2; + } } final message = messages[index - 2]; @@ -685,6 +711,15 @@ class _MessageListViewState extends State { .index; } + int? _getBottomElementIndex(Iterable values) { + final inView = values.where((position) => position.itemLeadingEdge < 1); + if (inView.isEmpty) return null; + return inView + .reduce((min, position) => + position.itemLeadingEdge < min.itemLeadingEdge ? position : min) + .index; + } + Widget _buildScrollToBottom() => StreamBuilder>( stream: Rx.combineLatest2( streamChannel!.channel.state!.isUpToDateStream.distinct(), @@ -814,7 +849,7 @@ class _MessageListViewState extends State { ) { final isMyMessage = message.user!.id == StreamChat.of(context).currentUser!.id; - final isOnlyEmoji = message.text!.isOnlyEmoji; + final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; final currentUser = StreamChat.of(context).currentUser; final members = StreamChannel.of(context).channel.state?.members ?? []; final currentUserMember = @@ -915,7 +950,7 @@ class _MessageListViewState extends State { } final channel = streamChannel!.channel; - final readList = channel.state?.read?.where((read) { + final readList = channel.state?.read.where((read) { if (read.user.id == userId) return false; return read.lastRead.isAfter(message.createdAt) || read.lastRead.isAtSameMomentAs(message.createdAt); @@ -953,7 +988,7 @@ class _MessageListViewState extends State { final showInChannelIndicator = !_isThreadConversation && isThreadMessage; final showThreadReplyIndicator = !_isThreadConversation && hasReplies; - final isOnlyEmoji = message.text!.isOnlyEmoji; + final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; final hasUrlAttachment = message.attachments.any((it) => it.ogScrapeUrl != null) == true; @@ -1163,6 +1198,13 @@ class _MessageListViewState extends State { initialIndex = _initialIndex; initialAlignment = _initialAlignment; + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + _scrollController?.jumpTo( + index: initialIndex, + alignment: initialAlignment, + ); + }); + _messageNewListener = streamChannel!.channel.on(EventType.messageNew).listen((event) { if (_upToDate) { @@ -1203,8 +1245,7 @@ class _MessageListViewState extends State { MaterialPageRoute( builder: (_) => BetterStreamBuilder( stream: streamChannel!.channel.state!.messagesStream.map( - (messages) => - messages!.firstWhere((m) => m.id == message.id)), + (messages) => messages.firstWhere((m) => m.id == message.id)), initialData: message, builder: (_, data) => StreamChannel( channel: streamChannel!.channel, diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 19af5b2a..ca1aa0bf 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -1048,7 +1048,7 @@ class _MessageWidgetState extends State messageWidget: widget.copyWith( key: const Key('MessageWidget'), message: widget.message.copyWith( - text: widget.message.text!.length > 200 + text: (widget.message.text?.length ?? 0) > 200 ? '${widget.message.text!.substring(0, 200)}...' : widget.message.text, ), @@ -1111,7 +1111,7 @@ class _MessageWidgetState extends State messageWidget: widget.copyWith( key: const Key('MessageWidget'), message: widget.message.copyWith( - text: widget.message.text!.length > 200 + text: (widget.message.text?.length ?? 0) > 200 ? '${widget.message.text!.substring(0, 200)}...' : widget.message.text, ), @@ -1258,7 +1258,7 @@ class _MessageWidgetState extends State ); Widget _buildTextBubble() { - if (widget.message.text!.trim().isEmpty) return const Offstage(); + if (widget.message.text?.trim().isEmpty ?? false) return const Offstage(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/packages/stream_chat_flutter/lib/src/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/unread_indicator.dart index 381bad39..e5f3027b 100644 --- a/packages/stream_chat_flutter/lib/src/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/unread_indicator.dart @@ -17,7 +17,7 @@ class UnreadIndicator extends StatelessWidget { Widget build(BuildContext context) { final client = StreamChat.of(context).client; return IgnorePointer( - child: BetterStreamBuilder( + child: BetterStreamBuilder( stream: cid != null ? client.state.channels[cid]?.state?.unreadCountStream : client.state.totalUnreadCountStream, @@ -25,7 +25,7 @@ class UnreadIndicator extends StatelessWidget { ? client.state.channels[cid]?.state?.unreadCount : client.state.totalUnreadCount, builder: (context, data) { - if (data == null || data == 0) { + if (data == 0) { return const Offstage(); } return Material( diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 89c6450c..42188fa0 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -12,6 +12,7 @@ export 'src/channel_preview.dart'; export 'src/connection_status_builder.dart'; export 'src/date_divider.dart'; export 'src/deleted_message.dart'; +export 'src/extension.dart' show IconButtonX; export 'src/full_screen_media.dart'; export 'src/gallery_footer.dart'; export 'src/gallery_header.dart'; diff --git a/packages/stream_chat_flutter/test/src/channel_image_test.dart b/packages/stream_chat_flutter/test/src/channel_image_test.dart index c9293e79..f6d110dd 100644 --- a/packages/stream_chat_flutter/test/src/channel_image_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_image_test.dart @@ -48,7 +48,7 @@ void main() { ); testWidgets( - 'it should show the the other member image', + 'it should show the other member image', (tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -74,9 +74,7 @@ void main() { userId: 'user-id2', user: User( id: 'user-id2', - extraData: const { - 'image': 'testimage', - }, + image: 'testimage', ), ) ])); @@ -85,9 +83,7 @@ void main() { userId: 'user-id2', user: User( id: 'user-id2', - extraData: const { - 'image': 'testimage', - }, + image: 'testimage', ), ), Member( @@ -98,9 +94,7 @@ void main() { when(() => clientState.usersStream).thenAnswer((i) => Stream.value({ 'user-id2': User( id: 'user-id2', - extraData: const { - 'image': 'testimage', - }, + image: 'testimage', ), })); when(() => channel.extraData).thenReturn({ @@ -149,27 +143,21 @@ void main() { userId: 'user-id', user: User( id: 'user-id', - extraData: const { - 'image': 'testimage1', - }, + image: 'testimage1', ), ), Member( userId: 'user-id2', user: User( id: 'user-id2', - extraData: const { - 'image': 'testimage2', - }, + image: 'testimage2', ), ), Member( userId: 'user-id3', user: User( id: 'user-id3', - extraData: const { - 'image': 'testimage3', - }, + image: 'testimage3', ), ), ]; diff --git a/packages/stream_chat_flutter/test/src/channel_preview_test.dart b/packages/stream_chat_flutter/test/src/channel_preview_test.dart index 7d0c549e..d6f3a5ea 100644 --- a/packages/stream_chat_flutter/test/src/channel_preview_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_preview_test.dart @@ -21,6 +21,8 @@ void main() { when(() => clientState.currentUser).thenReturn(user); when(() => clientState.currentUserStream) .thenAnswer((_) => Stream.value(user)); + when(() => channel.lastMessageAtStream) + .thenAnswer((_) => Stream.value(lastMessageAt)); when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); diff --git a/packages/stream_chat_flutter/test/src/default_translations_test.dart b/packages/stream_chat_flutter/test/src/default_translations_test.dart index e78c0115..14a4a318 100644 --- a/packages/stream_chat_flutter/test/src/default_translations_test.dart +++ b/packages/stream_chat_flutter/test/src/default_translations_test.dart @@ -167,7 +167,7 @@ void main() { expect(translations.withText, isNotNull); expect(translations.inText, isNotNull); expect(translations.youText, isNotNull); - expect(translations.ofText, isNotNull); + expect(translations.galleryPaginationText, isNotNull); expect(translations.fileText, isNotNull); expect(translations.replyToMessageLabel, isNotNull); }); diff --git a/packages/stream_chat_flutter/test/src/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input_test.dart index 679fb7f3..62fe0107 100644 --- a/packages/stream_chat_flutter/test/src/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input_test.dart @@ -69,4 +69,69 @@ void main() { expect(find.byKey(const Key('messageInputText')), findsOneWidget); }, ); + + testWidgets( + 'checks message input slow mode', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + when(() => channel.cooldown).thenReturn(10); + when(() => channel.cooldownStartedAt).thenReturn(DateTime.now()); + when(() => channel.client).thenReturn(client); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ + 'name': 'test', + })); + when(() => channel.extraData).thenReturn({ + 'name': 'test', + }); + when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ) + ])); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.messages).thenReturn([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ]); + when(() => channelState.messagesStream).thenAnswer((i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ])); + + await tester.pumpWidget(MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: MessageInput(), + ), + ), + ), + )); + + expect(find.text('Slow mode ON'), findsOneWidget); + }, + ); } diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index ef24e401..ad331bc2 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,14 @@ +## Upcoming + +🛑️ Breaking Changes from `2.1.1` +- Renamed `BetterStreamBuilder.loadingBuilder` to `.noDataBuilder` + +🔄 Changed +- `BetterStreamBuilder.initialData` is now nullable/not-required. + +🐞 Fixed +- [#612](https://github.com/GetStream/stream-chat-flutter/issues/612) `ChannelListView` pagination doesn't work after refresh + ## 2.1.1 - Updated llc dependency diff --git a/packages/stream_chat_flutter_core/README.md b/packages/stream_chat_flutter_core/README.md index 462498f6..e625aeeb 100644 --- a/packages/stream_chat_flutter_core/README.md +++ b/packages/stream_chat_flutter_core/README.md @@ -26,7 +26,7 @@ It teaches you how to use this SDK and also shows how to make frequently require ## Example App This repo includes a fully functional example app with setup instructions. -The example is available under the [example](https://github.com/GetStream/stream-chat-flutter-core/tree/master/example) folder. +The example is available under the [example](https://github.com/GetStream/stream-chat-flutter/tree/main/packages/stream_chat_flutter_core/example) folder. ## Add dependency Add this to your package's pubspec.yaml file, use the latest version [![Pub](https://img.shields.io/pub/v/stream_chat_flutter_core.svg)](https://pub.dartlang.org/packages/stream_chat_flutter_core) diff --git a/packages/stream_chat_flutter_core/example/lib/main.dart b/packages/stream_chat_flutter_core/example/lib/main.dart index 03e791de..31410e63 100644 --- a/packages/stream_chat_flutter_core/example/lib/main.dart +++ b/packages/stream_chat_flutter_core/example/lib/main.dart @@ -329,21 +329,9 @@ class _MessageScreenState extends State { } } -/// Extensions can be used to add functionality to the SDK. In the examples -/// below, we add two simple extensions to the [StreamChatClient] and [Channel]. +/// Extensions can be used to add functionality to the SDK. In the example +/// below, we add a simple extensions to the [StreamChatClient]. extension on StreamChatClient { /// Fetches the current user id. String get uid => state.currentUser!.id; } - -extension on Channel { - /// Fetches the name of the channel by accessing [extraData] or [cid]. - String? get name { - final _channelName = extraData['name']; - if (_channelName != null) { - return _channelName as String; - } else { - return cid; - } - } -} diff --git a/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart index 871a8a1d..3c456246 100644 --- a/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart +++ b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart @@ -6,23 +6,23 @@ import 'package:flutter/widgets.dart'; /// It requires [initialData] and will rebuild /// only when the new data is different than the current data /// The [comparator] is used to check if the new data is different -class BetterStreamBuilder extends StatefulWidget { +class BetterStreamBuilder extends StatefulWidget { /// Creates a new BetterStreamBuilder const BetterStreamBuilder({ required this.stream, - required this.initialData, required this.builder, - this.loadingBuilder, + this.initialData, + this.noDataBuilder, this.errorBuilder, this.comparator, Key? key, }) : super(key: key); /// The stream to listen to - final Stream? stream; + final Stream? stream; /// The initial data available - final T initialData; + final T? initialData; /// Comparator used to check if the new data is different than the last one final bool Function(T?, T?)? comparator; @@ -31,7 +31,7 @@ class BetterStreamBuilder extends StatefulWidget { final Widget Function(BuildContext context, T data) builder; /// Builder that builds when the data is null - final Widget Function(BuildContext context)? loadingBuilder; + final Widget Function(BuildContext context)? noDataBuilder; /// Builder used when there is an error final Widget Function(BuildContext context, Object error)? errorBuilder; @@ -40,21 +40,26 @@ class BetterStreamBuilder extends StatefulWidget { _BetterStreamBuilderState createState() => _BetterStreamBuilderState(); } -class _BetterStreamBuilderState extends State> { +class _BetterStreamBuilderState + extends State> { T? _lastEvent; - StreamSubscription? _subscription; + StreamSubscription? _subscription; Object? _lastError; @override Widget build(BuildContext context) { - if (_lastError != null) { - return widget.errorBuilder!(context, _lastError!); + final error = _lastError; + if (error != null) { + final errorBuilder = widget.errorBuilder; + if (errorBuilder != null) { + return errorBuilder(context, error); + } } - - if (_lastEvent == null) { - return widget.loadingBuilder?.call(context) ?? const Offstage(); + final event = _lastEvent; + if (event == null) { + return widget.noDataBuilder?.call(context) ?? const Offstage(); } - return widget.builder(context, _lastEvent ?? widget.initialData); + return widget.builder(context, event); } @override @@ -87,22 +92,22 @@ class _BetterStreamBuilderState extends State> { void _onError(error) { if (widget.errorBuilder != null && error != _lastError) { + _lastError = error; if (mounted) { setState(() {}); } - _lastError = error; } } - void _onEvent(T event) { + void _onEvent(T? event) { _lastError = null; final isEqual = widget.comparator?.call(_lastEvent, event) ?? event == _lastEvent; if (!isEqual) { + _lastEvent = event; if (mounted) { setState(() {}); } - _lastEvent = event; } } } diff --git a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart index c59b2030..c5d3f69a 100644 --- a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; import 'package:stream_chat_flutter_core/src/channels_bloc.dart'; import 'package:stream_chat_flutter_core/src/stream_chat_core.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart'; @@ -137,19 +138,14 @@ class ChannelListCoreState extends State { @override Widget build(BuildContext context) => _buildListView(_channelsBloc); - StreamBuilder> _buildListView( + BetterStreamBuilder> _buildListView( ChannelsBlocState channelsBlocState, ) => - StreamBuilder>( + BetterStreamBuilder>( stream: channelsBlocState.channelsStream, - builder: (context, snapshot) { - if (snapshot.hasError) { - return widget.errorBuilder(context, snapshot.error!); - } - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } - final channels = snapshot.data!; + errorBuilder: widget.errorBuilder, + noDataBuilder: widget.loadingBuilder, + builder: (context, channels) { if (channels.isEmpty) { return widget.emptyBuilder(context); } diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 46aaccee..6687eef5 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -106,6 +106,9 @@ class ChannelsBlocState extends State final client = _streamChatCoreState!.client; final clear = paginationParams.offset == 0; + if (clear && _paginationEnded) { + _paginationEnded = false; + } if ((!clear && _paginationEnded) || _queryChannelsLoadingController.value == true) { diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index d3d69dfb..b9b423ce 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -138,7 +138,7 @@ class MessageListCoreState extends State { return true; } - return BetterStreamBuilder?>( + return BetterStreamBuilder>( initialData: initialData, comparator: const ListEquality().equals, stream: messagesStream!.map( @@ -148,9 +148,9 @@ class MessageListCoreState extends State { ), ), errorBuilder: widget.errorBuilder, - loadingBuilder: widget.loadingBuilder, + noDataBuilder: widget.loadingBuilder, builder: (context, data) { - final messageList = data?.reversed.toList(growable: false) ?? []; + final messageList = data.reversed.toList(growable: false); if (messageList.isEmpty && !_isThreadConversation) { if (_upToDate) { return widget.emptyBuilder(context); diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart index 4901c332..234b5c9a 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; import 'package:stream_chat_flutter_core/src/message_search_bloc.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart'; @@ -132,16 +133,11 @@ class MessageSearchListCoreState extends State { Widget build(BuildContext context) => _buildListView(_messageSearchBloc!); Widget _buildListView(MessageSearchBlocState messageSearchBloc) => - StreamBuilder>( + BetterStreamBuilder>( stream: messageSearchBloc.messagesStream, - builder: (context, snapshot) { - if (snapshot.hasError) { - return widget.errorBuilder(context, snapshot.error!); - } - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } - final items = snapshot.data!; + errorBuilder: widget.errorBuilder, + noDataBuilder: widget.loadingBuilder, + builder: (context, items) { if (items.isEmpty) { return widget.emptyBuilder(context); } diff --git a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart index a45aa904..57d61fe6 100644 --- a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart @@ -176,16 +176,11 @@ class UserListCoreState extends State }, ); - StreamBuilder> _buildListView() => StreamBuilder( + BetterStreamBuilder> _buildListView() => BetterStreamBuilder( stream: _buildUserStream(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return widget.errorBuilder(context, snapshot.error!); - } - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } - final items = snapshot.data!; + errorBuilder: widget.errorBuilder, + noDataBuilder: widget.loadingBuilder, + builder: (context, items) { if (items.isEmpty) { return widget.emptyBuilder(context); } diff --git a/packages/stream_chat_flutter_core/test/channels_bloc_test.dart b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart index a237a23c..2aee9eba 100644 --- a/packages/stream_chat_flutter_core/test/channels_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart @@ -558,6 +558,7 @@ void main() { config: ChannelConfig(), createdAt: DateTime.now(), memberCount: 1, + cooldown: 0, ), ); diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 865741c4..8e13d154 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,7 +1,20 @@ ## Upcoming -* Added support for [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) locale. +✅ Added +* Added support for [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) locale. +* Added support for [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart) locale. +* Added support for [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart) locale. +* Added strings for cooldown mode. + +🔄 Changed + +* Some of the `Hindi` translations have been updated/changed for better understanding. + - 'रिप्लाई' -> 'जवाब दें' + - 'तस्वीरें' -> 'फ़ोटोज' + - 'बिता हुआ कल' -> 'कल' + - 'चैनल मौन है' -> 'चैनल म्यूट है' + ## 1.0.2 * Updated `stream_chat_flutter` dependency diff --git a/packages/stream_chat_localizations/README.md b/packages/stream_chat_localizations/README.md index c2ea8a72..131d6b13 100644 --- a/packages/stream_chat_localizations/README.md +++ b/packages/stream_chat_localizations/README.md @@ -35,6 +35,8 @@ At the moment we support the following languages: - [Italian](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart) - [French](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart) - [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) +- [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart) +- [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart) More languages will be added in the future. Feel free to [contribute](https://github.com/GetStream/stream-chat-flutter/blob/master/CONTRIBUTING.md) to add more languages. @@ -70,6 +72,8 @@ class MyApp extends StatelessWidget { Locale('fr'), Locale('it'), Locale('es'), + Locale('ja'), + Locale('ko'), ], // Add GlobalStreamChatLocalizations.delegates localizationsDelegates: GlobalStreamChatLocalizations.delegates, @@ -113,6 +117,8 @@ Example: fr it es + ja + ko ``` 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 8d2109f6..364761a2 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -374,7 +374,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get youText => 'You'; @override - String get ofText => 'of'; + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '$currentPage of $totalPages'; @override String get fileText => 'File'; @@ -385,6 +387,8 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String attachmentLimitExceedError(int limit) => 'Attachment limit exceeded, limit: $limit'; + + String get slowModeOnLabel => 'Slow mode ON'; } void main() async { @@ -457,6 +461,9 @@ class MyApp extends StatelessWidget { Locale('hi'), Locale('fr'), Locale('it'), + Locale('es'), + Locale('ja'), + Locale('ko'), // Add support for additional 'nn' locale Locale('nn'), ], diff --git a/packages/stream_chat_localizations/example/lib/main.dart b/packages/stream_chat_localizations/example/lib/main.dart index c99a4c6c..c71ceb01 100644 --- a/packages/stream_chat_localizations/example/lib/main.dart +++ b/packages/stream_chat_localizations/example/lib/main.dart @@ -72,6 +72,9 @@ class MyApp extends StatelessWidget { Locale('hi'), Locale('fr'), Locale('it'), + Locale('es'), + Locale('ja'), + Locale('ko'), ], // Add GlobalStreamChatLocalizations.delegates localizationsDelegates: GlobalStreamChatLocalizations.delegates, diff --git a/packages/stream_chat_localizations/example/lib/override_lang.dart b/packages/stream_chat_localizations/example/lib/override_lang.dart index 5be8f9a6..a52fcf9f 100644 --- a/packages/stream_chat_localizations/example/lib/override_lang.dart +++ b/packages/stream_chat_localizations/example/lib/override_lang.dart @@ -97,6 +97,9 @@ class MyApp extends StatelessWidget { Locale('hi'), Locale('fr'), Locale('it'), + Locale('es'), + Locale('ja'), + Locale('ko'), ], // Add overridden "CustomStreamChatLocalizationsEn.delegate" along with // "GlobalStreamChatLocalizations.delegates" diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart index a6a66b93..4094dcaa 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart @@ -11,6 +11,10 @@ part 'stream_chat_localizations_fr.dart'; part 'stream_chat_localizations_it.dart'; +part 'stream_chat_localizations_ja.dart'; + +part 'stream_chat_localizations_ko.dart'; + part 'stream_chat_localizations_hi.dart'; /// The set of supported languages, as language code strings. @@ -27,6 +31,8 @@ const kStreamChatSupportedLanguages = { 'fr', 'it', 'es', + 'ja', + 'ko' }; /// Creates a [GlobalStreamChatLocalizations] instance for the given `locale`. @@ -59,6 +65,10 @@ GlobalStreamChatLocalizations? getStreamChatTranslation(Locale locale) { return const StreamChatLocalizationsIt(); case 'es': return const StreamChatLocalizationsEs(); + case 'ja': + return const StreamChatLocalizationsJa(); + case 'ko': + return const StreamChatLocalizationsKo(); } } 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 8c905b41..5cb59755 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 @@ -350,7 +350,9 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get youText => 'You'; @override - String get ofText => 'of'; + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '${currentPage + 1} of $totalPages'; @override String get fileText => 'File'; @@ -361,4 +363,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String attachmentLimitExceedError(int limit) => 'Attachment limit exceeded, limit: $limit'; + + @override + String get slowModeOnLabel => 'Slow mode ON'; } 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 10d9b2a7..aac8f1eb 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 @@ -355,7 +355,9 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get youText => 'Usted'; @override - String get ofText => 'de'; + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '${currentPage + 1} de $totalPages'; @override String get fileText => 'Archivo'; @@ -368,4 +370,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { // TODO: implement attachmentLimitExceedError throw UnimplementedError(); } + + @override + String get slowModeOnLabel => 'Modo lento activado'; } 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 17efdd70..ce8debf7 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 @@ -207,7 +207,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String togglePinUnpinText({required bool pinned}) { if (pinned) return 'Décrocher de la conversation'; - return 'Épingler à la discussion'; + return 'Épingler à la conversation'; } @override @@ -354,7 +354,9 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get youText => 'Vous'; @override - String get ofText => 'de'; + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '${currentPage + 1} de $totalPages'; @override String get fileText => 'Fichier'; @@ -367,4 +369,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { // TODO: implement attachmentLimitExceedError throw UnimplementedError(); } + + @override + String get slowModeOnLabel => 'Mode lent activé'; } 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 c08f77a2..e80979c3 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 @@ -197,7 +197,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { 'कार्रवाई पूरी नहीं की जा सकी.'; @override - String get replyLabel => 'रिप्लाई'; + String get replyLabel => 'जवाब दें'; @override String togglePinUnpinText({required bool pinned}) { @@ -224,7 +224,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { } @override - String get photosLabel => 'तस्वीरें'; + String get photosLabel => 'फ़ोटोज'; String _getDay(DateTime dateTime) { final now = DateTime.now(); @@ -250,10 +250,10 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get todayLabel => 'आज'; @override - String get yesterdayLabel => 'बिता हुआ कल'; + String get yesterdayLabel => 'कल'; @override - String get channelIsMutedText => 'चैनल मौन है'; + String get channelIsMutedText => 'चैनल म्यूट है'; @override String get noTitleText => 'कोई शीर्षक नहीं'; @@ -340,16 +340,18 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get sendLabel => 'भेजें'; @override - String get withText => 'विद'; + String get withText => 'विद'; //TODO: break? @override - String get inText => 'इन'; + String get inText => 'इन'; //TODO: break? @override String get youText => 'आप'; @override - String get ofText => 'ऑफ़'; + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '${currentPage + 1} ऑफ़ $totalPages'; @override String get fileText => 'फ़ाइल'; @@ -362,4 +364,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { // TODO: implement attachmentLimitExceedError throw UnimplementedError(); } + + @override + String get slowModeOnLabel => 'स्लो मोड चालू'; } 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 084878ee..abe2b3a8 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 @@ -351,7 +351,9 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get youText => 'te'; @override - String get ofText => 'di'; + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '${currentPage + 1} di $totalPages'; @override String get fileText => 'file'; @@ -364,4 +366,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; // TODO: implement attachmentLimitExceedError throw UnimplementedError(); } + + @override + String get slowModeOnLabel => 'Slowmode attiva'; } 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 new file mode 100644 index 00000000..533b84fd --- /dev/null +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -0,0 +1,358 @@ +part of 'stream_chat_localizations.dart'; + +/// The translations for Japanese (`ja`). +class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { + /// Create an instance of the translation bundle for Japanese. + const StreamChatLocalizationsJa({String localeName = 'ja'}) + : super(localeName: localeName); + + @override + String get launchUrlError => 'URLの起動ができません'; + + @override + String get loadingUsersError => 'ユーザーの読み込みができません'; + + @override + String get noUsersLabel => '現在、ユーザーはいません。'; + + @override + String get retryLabel => '再試行'; + + @override + String get userLastOnlineText => '前回のオンライン'; + + @override + String get userOnlineText => 'オンライン'; + + @override + String userTypingText(Iterable users) { + if (users.isEmpty) return ''; + final first = users.first; + if (users.length == 1) { + return '${first.name}が入力しています'; + } + return '${first.name}と${users.length - 1}人が入力しています'; + } + + @override + String get threadReplyLabel => 'スレッド返信'; + + @override + String get onlyVisibleToYouText => '自分しか見れません'; + + @override + String threadReplyCountText(int count) => '$countつのスレッド返信'; + + @override + String attachmentsUploadProgressText({ + required int remaining, + required int total, + }) => + '$remaining/${total}mbのアップロード中 。。。'; + + @override + String pinnedByUserText({ + required User pinnedBy, + required User currentUser, + }) { + final pinnedByCurrentUser = currentUser.id == pinnedBy.id; + if (pinnedByCurrentUser) return 'あなたのピン'; + return '${pinnedBy.name}のピン'; + } + + @override + String get emptyMessagesText => '現在、メッセージはありません。'; + + @override + String get genericErrorText => 'エラーが発生しました'; + + @override + String get loadingMessagesError => 'メッセージの読み込みエラー'; + + @override + String resultCountText(int count) => '$count件の結果'; + + @override + String get messageDeletedText => 'このメッセージは削除されました。'; + + @override + String get messageDeletedLabel => 'メッセージ削除'; + + @override + String get messageReactionsLabel => 'メッセージのリアクション'; + + @override + String get emptyChatMessagesText => 'チャットがありませんが。。。'; + + @override + String threadSeparatorText(int replyCount) => '$replyCount件の返信'; + + @override + String get connectedLabel => '接続しています'; + + @override + String get disconnectedLabel => '接続切れ'; + + @override + String get reconnectingLabel => '再接続中。。。'; + + @override + String get alsoSendAsDirectMessageLabel => 'ダイレクトメッセージでも送信'; + + @override + String get addACommentOrSendLabel => 'コメントの追加や送信'; + + @override + String get searchGifLabel => 'GIFの検索'; + + @override + String get writeAMessageLabel => 'メッセージを書く'; + + @override + String get instantCommandsLabel => 'インスタントコマンド'; + + @override + String fileTooLargeAfterCompressionError(double limitInMB) => + 'ファイルのサイズが大きすぎてアップロードできません。' + 'ファイルサイズの制限は${limitInMB}MBです。' + '圧縮してみましたが、十分ではありませんでした。'; + + @override + String fileTooLargeError(double limitInMB) => + 'ファイルが大きすぎてアップロードできません。ファイルサイズの制限は${limitInMB}MBです。'; + + @override + String emojiMatchingQueryText(String query) => '「"$query"」とお揃いの絵文字'; + + @override + String get addAFileLabel => 'ファイルの追加'; + + @override + String get photoFromCameraLabel => 'カメラからの写真'; + + @override + String get uploadAFileLabel => 'ファイルのアップロード'; + + @override + String get uploadAPhotoLabel => '写真のアップロード'; + + @override + String get uploadAVideoLabel => '動画のアップロード'; + + @override + String get videoFromCameraLabel => 'カメラからの動画'; + + @override + String get okLabel => 'よし'; + + @override + String get somethingWentWrongError => 'エラーが発生しました'; + + @override + String get addMoreFilesLabel => 'ファイルの追加'; + + @override + String get enablePhotoAndVideoAccessMessage => 'お友達と共有できるように、写真' + '\nやビデオへのアクセスを有効にしてください。'; + @override + String get allowGalleryAccessMessage => 'ギャラリーへのアクセスを許可する'; + + @override + String get flagMessageLabel => 'メッセージをフラグする'; + + @override + String get flagMessageQuestion => 'このメッセージのコピーを' + '\nモデレーターに送って、さらに調査してもらいますか?'; + + @override + String get flagLabel => 'フラグする'; + + @override + String get cancelLabel => 'キャンセル'; + + @override + String get flagMessageSuccessfulLabel => 'メッセージにフラグが付けられました'; + + @override + String get flagMessageSuccessfulText => 'このメッセージはモデレーターに報告されました。'; + + @override + String get deleteLabel => '削除'; + + @override + String get deleteMessageLabel => 'メッセージを削除する '; + + @override + String get deleteMessageQuestion => 'このメッセージ' + '\nを完全に削除してもよろしいですか?'; + + @override + String get operationCouldNotBeCompletedText => '操作を完了できませんでした。'; + + @override + String get replyLabel => '返信'; + + @override + String togglePinUnpinText({required bool pinned}) { + if (pinned) return '会話のピンを外す'; + return '会話にピンする'; + } + + @override + String toggleDeleteRetryDeleteMessageText({required bool isDeleteFailed}) { + if (isDeleteFailed) return 'メッセージの削除を再試行する'; + return 'メッセージを削除する'; + } + + @override + String get copyMessageLabel => 'メッセージをコピーする'; + + @override + String get editMessageLabel => 'メッセージを編集する'; + + @override + String toggleResendOrResendEditedMessage({required bool isUpdateFailed}) { + if (isUpdateFailed) return '編集したメッセージを再送する'; + return '再送'; + } + + @override + String get photosLabel => '写真'; + + String _getDay(DateTime dateTime) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = DateTime(now.year, now.month, now.day - 1); + + final date = DateTime(dateTime.year, dateTime.month, dateTime.day); + + if (date == today) { + return '今日'; + } else if (date == yesterday) { + return '昨日'; + } else { + return '${Jiffy(date).MMMd}に'; + } + } + + @override + String sentAtText({required DateTime date, required DateTime time}) => + '${_getDay(date)}の${Jiffy(time.toLocal()).format('HH:mm')}に送信しました '; + + @override + String get todayLabel => '今日'; + + @override + String get yesterdayLabel => '昨日'; + + @override + String get channelIsMutedText => 'チャンネルが無音されています'; + + @override + String get noTitleText => 'タイトル無し'; + + @override + String get letsStartChattingLabel => 'チャットを始めよう!'; + + @override + String get sendingFirstMessageLabel => '友人に最初のメッセージを送りましょうか?'; + + @override + String get startAChatLabel => 'チャットを開始する'; + + @override + String get loadingChannelsError => 'チャネルのロード中にエラーが発生しました'; + + @override + String get deleteConversationLabel => '会話を削除する'; + + @override + String get deleteConversationQuestion => '本当に会話を削除しますか?'; + + @override + String get streamChatLabel => 'ストリームチャット'; + + @override + String get searchingForNetworkText => 'ネットワークを検索中'; + + @override + String get offlineLabel => 'オフライン。。。'; + + @override + String get tryAgainLabel => '再試行する'; + + @override + String membersCountText(int count) => '$count人のメンバー'; + + @override + String watchersCountText(int count) => '$count人がオンライン'; + + @override + String get viewInfoLabel => '情報を見る'; + + @override + String get leaveGroupLabel => 'グループを離れる'; + + @override + String get leaveLabel => '離れる'; + + @override + String get leaveConversationLabel => '会話を離れる'; + + @override + String get leaveConversationQuestion => '本当に会話を離れますか?'; + + @override + String get showInChatLabel => 'チャットで表示'; + + @override + String get saveImageLabel => '画像を保存'; + + @override + String get saveVideoLabel => 'ビデオを保存'; + + @override + String get uploadErrorLabel => 'アップロードエラー'; + + @override + String get giphyLabel => 'ギフィー'; + + @override + String get shuffleLabel => 'ミックス'; + + @override + String get sendLabel => '送信'; + + @override + String get withText => 'と'; + + @override + String get inText => 'に'; + + // This is the word for 'customer' or 'user' because saying 'you' directly + //is too informal and rude + @override + String get youText => 'あなた'; + + @override + String galleryPaginationText({ + required int currentPage, + required int totalPages, + }) => + '${currentPage + 1} / $totalPages'; + + @override + String get fileText => 'ファイル'; + + @override + String get replyToMessageLabel => 'メッセージに返信'; + + @override + String get slowModeOnLabel => 'スローモードオン'; + + @override + String attachmentLimitExceedError(int limit) { + // TODO: implement attachmentLimitExceedError + throw UnimplementedError(); + } +} 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 new file mode 100644 index 00000000..9a3a1315 --- /dev/null +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -0,0 +1,356 @@ +part of 'stream_chat_localizations.dart'; + +/// The translations for Korean (`ko`). +class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { + /// Create an instance of the translation bundle for Korean. + const StreamChatLocalizationsKo({String localeName = 'ko'}) + : super(localeName: localeName); + + @override + String get launchUrlError => 'URL을 시작할 수 없습니다'; + + @override + String get loadingUsersError => '사용자를 로드하는 중 오류 발생'; + + @override + String get noUsersLabel => '현재 사용자가 없습니다'; + + @override + String get retryLabel => '다시 시도하십시오'; + + @override + String get userLastOnlineText => '마지막 온라인입니다'; + + @override + String get userOnlineText => '온라인'; + + @override + String userTypingText(Iterable users) { + if (users.isEmpty) return ''; + final first = users.first; + if (users.length == 1) { + return '${first.name} 타이핑중'; + } + return '${first.name}하고 ${users.length - 1}명 타이핑중'; + } + + @override + String get threadReplyLabel => '스레드 응답입니다'; + + @override + String get onlyVisibleToYouText => '당신만 볼 수 있습니다'; + + @override + String threadReplyCountText(int count) => '$count스레드 답장'; + + @override + String attachmentsUploadProgressText({ + required int remaining, + required int total, + }) => + '$remaining/${total}mb를 업로드중...'; + + @override + String pinnedByUserText({ + required User pinnedBy, + required User currentUser, + }) { + final pinnedByCurrentUser = currentUser.id == pinnedBy.id; + if (pinnedByCurrentUser) return '당신의 핀'; + return '${pinnedBy.name}의 핀'; + } + + @override + String get emptyMessagesText => '현재 메시지가 없습니다'; + + @override + String get genericErrorText => '뭔가 잘못됐습니다'; + + @override + String get loadingMessagesError => '메시지를 로드하는 동안 오류가 발생했습니다'; + + @override + String resultCountText(int count) => '$count개의 결과'; + + @override + String get messageDeletedText => '이 메시지는 삭제되었습니다.'; + + @override + String get messageDeletedLabel => '메시지가 삭제되었습니다'; + + @override + String get messageReactionsLabel => '메시지에 대한 응답'; + + @override + String get emptyChatMessagesText => '아직 채팅이 없습니다...'; + + @override + String threadSeparatorText(int replyCount) => '$replyCount개의 답장'; + + @override + String get connectedLabel => '연결중'; + + @override + String get disconnectedLabel => '연결이 끊겼습니다'; + + @override + String get reconnectingLabel => '다시 연결하는 중...'; + + @override + String get alsoSendAsDirectMessageLabel => '다이렉트 메시지로도 보냅니다'; + + @override + String get addACommentOrSendLabel => '주석을 추가하거나 보냅니다'; + + @override + String get searchGifLabel => 'GIF 검색'; + + @override + String get writeAMessageLabel => '메시지 쓰기'; + + @override + String get instantCommandsLabel => '인스턴트 커맨즈'; + + @override + String fileTooLargeAfterCompressionError(double limitInMB) => + '파일이 너무 커서 업로드할 수 없습니다. ' + '파일 크기 제한은 ${limitInMB}MB입니다. ' + '우리는 압축해 보았지만 충분하지 않았습니다.'; + + @override + String fileTooLargeError(double limitInMB) => + '파일이 너무 커서 업로드할 수 없습니다. 파일 크기 제한은 ${limitInMB}MB입니다.'; + + @override + String emojiMatchingQueryText(String query) => '"$query"과 일치하는 이모티콘입니다'; + + @override + String get addAFileLabel => '파일을 추가함'; + + @override + String get photoFromCameraLabel => '카메라에서 찍은 사진'; + + @override + String get uploadAFileLabel => '파일을 업로드함'; + + @override + String get uploadAPhotoLabel => '사진을 업로드함'; + + @override + String get uploadAVideoLabel => '비디오를 업로드함'; + + @override + String get videoFromCameraLabel => '카메라의 비디오.'; + + @override + String get okLabel => '확인'; + + @override + String get somethingWentWrongError => '뭔가 잘못됐습느다'; + + @override + String get addMoreFilesLabel => '파일을 추가함'; + + @override + String get enablePhotoAndVideoAccessMessage => '친구와 공유할 수 있도록 사진과' + '\n동영상에 액세스할 수 있도록 설정하십시오.'; + + @override + String get allowGalleryAccessMessage => '갤러리에 대한 액세스를 허용합니다'; + + @override + String get flagMessageLabel => ' 메시지를 플래그함'; + + @override + String get flagMessageQuestion => '추가 조사를 위해 진행자에게 이 메시지의 복사본을 전송하시겠습니까?'; + + @override + String get flagLabel => '플래그함'; + + @override + String get cancelLabel => '취소'; + + @override + String get flagMessageSuccessfulLabel => '메시지에 플래그가 지정되었습니다'; + + @override + String get flagMessageSuccessfulText => '메시지가 진행자에게 보고되었습니다.'; + + @override + String get deleteLabel => '삭제'; + + @override + String get deleteMessageLabel => '메시지를 삭제합니다.'; + + @override + String get deleteMessageQuestion => '이 메시지를 완전히 삭제하시겠습니까?'; + + @override + String get operationCouldNotBeCompletedText => '작업을 완료할 수 없습니다.'; + + @override + String get replyLabel => '답글'; + + @override + String togglePinUnpinText({required bool pinned}) { + if (pinned) return '대화의 핀을 분리합니다'; + return '대화에 고정합니다'; + } + + @override + String toggleDeleteRetryDeleteMessageText({required bool isDeleteFailed}) { + if (isDeleteFailed) return '메시지 삭제를 다시 시도합니다'; + return '메시지를 삭제합니다'; + } + + @override + String get copyMessageLabel => '메시지를 복사합니다.'; + + @override + String get editMessageLabel => '메시지를 편집합니다.'; + + @override + String toggleResendOrResendEditedMessage({required bool isUpdateFailed}) { + if (isUpdateFailed) return '편집된 메시지를 다시 보냅니다.'; + return '다시 보냅니다.'; + } + + @override + String get photosLabel => '사진'; + + String _getDay(DateTime dateTime) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = DateTime(now.year, now.month, now.day - 1); + + final date = DateTime(dateTime.year, dateTime.month, dateTime.day); + + if (date == today) { + return '오늘'; + } else if (date == yesterday) { + return '어제'; + } else { + return '${Jiffy(date).MMMd}에'; + } + } + + @override + String sentAtText({required DateTime date, required DateTime time}) => + '${_getDay(date)} ${Jiffy(time.toLocal()).format('HH:mm')}에 보냈습니다'; + + @override + String get todayLabel => '오늘'; + + @override + String get yesterdayLabel => '어제'; + + @override + String get channelIsMutedText => '채널이 음소거됩니다.'; + + @override + String get noTitleText => '제목이 없습니다.'; + + @override + String get letsStartChattingLabel => '채팅 시작해요!'; + + @override + String get sendingFirstMessageLabel => '친구에게 첫 메시지를 보내 볼까요?'; + + @override + String get startAChatLabel => '대화를 시작합니다.'; + + @override + String get loadingChannelsError => '채널을 로드하는 동안 오류가 발생했습니다.'; + + @override + String get deleteConversationLabel => '대화를 삭제합니다.'; + + @override + String get deleteConversationQuestion => '대화를 삭제하시겠습니까?'; + + @override + String get streamChatLabel => '스트림 채팅'; + + @override + String get searchingForNetworkText => '네트워크를 검색하는 중입니다.'; + + @override + String get offlineLabel => '오프라인...'; + + @override + String get tryAgainLabel => '다시 시도합니다'; + + @override + String membersCountText(int count) => '$count명'; + + @override + String watchersCountText(int count) => '$count명이 온라인'; + + @override + String get viewInfoLabel => '정보를 보기'; + + @override + String get leaveGroupLabel => '그룹을 떠납니다.'; + + @override + String get leaveLabel => '떠나다'; + + @override + String get leaveConversationLabel => '대화에서 떠납니다.'; + + @override + String get leaveConversationQuestion => '정말 이 대화에서 나가시겠습니까?'; + + @override + String get showInChatLabel => '채팅에 표시합니다.'; + + @override + String get saveImageLabel => '이미지를 저장합니다.'; + + @override + String get saveVideoLabel => '비디오를 저장합니다.'; + + @override + String get uploadErrorLabel => '업로드 오류'; + + @override + String get giphyLabel => '지피'; + + @override + String get shuffleLabel => '섞기'; + + @override + String get sendLabel => '보내기'; + + @override + String get withText => '함께'; + + @override + String get inText => '에'; + + // This is the word for 'customer' or 'user' because saying 'you' directly + // is too informal and rude + @override + String get youText => '당신'; + + @override + String galleryPaginationText( + {required int currentPage, required int totalPages}) => + '${currentPage + 1} / $totalPages'; + //3 / 11 + + @override + String get fileText => '파일'; + + @override + String get replyToMessageLabel => '메시지에 회신합니다.'; + + @override + String get slowModeOnLabel => '슬로모드 켜짐'; + + @override + String attachmentLimitExceedError(int limit) { + // TODO: implement attachmentLimitExceedError + throw UnimplementedError(); + } +} diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index f4eca409..669fd285 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -174,7 +174,7 @@ void main() { expect(localizations.withText, isNotNull); expect(localizations.inText, isNotNull); expect(localizations.youText, isNotNull); - expect(localizations.ofText, isNotNull); + expect(localizations.galleryPaginationText, isNotNull); expect(localizations.fileText, isNotNull); expect(localizations.replyToMessageLabel, isNotNull); expect(localizations.attachmentLimitExceedError(3), isNotNull);