diff --git a/.github/workflows/scripts/install-flutter.sh b/.github/workflows/scripts/install-flutter.sh deleted file mode 100755 index 247d2797..00000000 --- a/.github/workflows/scripts/install-flutter.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -BRANCH=$1 - -if [ "$BRANCH" == "dev" ] -then - # TODO Flutter dev branch is currently broken so we're unable to test MacOS. - echo "TODO: Skipping macOS testing due to Flutter dev branch issue. Switching branch to stable." - BRANCH=stable -fi - -git clone https://github.com/flutter/flutter.git --depth 1 -b $BRANCH _flutter -echo "::add-path::$GITHUB_WORKSPACE/_flutter/bin" \ No newline at end of file diff --git a/.github/workflows/scripts/install-tools.sh b/.github/workflows/scripts/install-tools.sh deleted file mode 100755 index 087cfecb..00000000 --- a/.github/workflows/scripts/install-tools.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -flutter pub global activate melos -echo "::add-path::$HOME/.pub-cache/bin" -echo "::add-path::$GITHUB_WORKSPACE/_flutter/.pub-cache/bin" -echo "::add-path::$GITHUB_WORKSPACE/_flutter/bin/cache/dart-sdk/bin" \ No newline at end of file diff --git a/.github/workflows/scripts/coverage.sh b/.github/workflows/scripts/remove-from-coverage.sh similarity index 69% rename from .github/workflows/scripts/coverage.sh rename to .github/workflows/scripts/remove-from-coverage.sh index edff4ae4..c6850e89 100755 --- a/.github/workflows/scripts/coverage.sh +++ b/.github/workflows/scripts/remove-from-coverage.sh @@ -3,4 +3,4 @@ # Fast fail the script on failures. set -e -pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r '\.freezed\.dart$' \ No newline at end of file +pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r '\.freezed\.dart$' diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 95f48ee9..6817b269 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -2,6 +2,7 @@ name: stream_flutter_workflow env: ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' + flutter_version: "2.2.2" on: pull_request: @@ -9,97 +10,114 @@ on: branches: - master - develop - paths-ignore: - - 'docs/**' jobs: analyze: timeout-minutes: 15 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: "Git Checkout" + uses: actions/checkout@v2 with: fetch-depth: 0 - - name: 'Install Flutter' - run: ./.github/workflows/scripts/install-flutter.sh stable - - name: 'Install Tools' + - name: Cache Flutter dependencies + uses: actions/cache@v2 + with: + path: /opt/hostedtoolcache/flutter + key: ${{ env.flutter_version }}-flutter + - name: "Install Flutter" + uses: subosito/flutter-action@v1 + with: + flutter-version: ${{ env.flutter_version }} + - name: "Install Tools" run: | - ./.github/workflows/scripts/install-tools.sh - flutter pub global activate tuneup - - name: 'Bootstrap Workspace' - run: melos bootstrap --verbose - - name: 'Dart Analyze' + flutter pub global activate melos + - name: "Bootstrap Workspace" + run: melos bootstrap + - name: "Dart Analyze" run: | - melos exec -c 3 --ignore="*example*" -- \ - tuneup check - - name: 'Pub Check' + melos run analyze + - name: "Pub Check" if: github.ref == 'refs/heads/master' run: | - melos exec -c 1 --no-private --ignore="*example*" -- \ - pub publish --dry-run + melos run lint:pub + format: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v2 + - name: "Git Checkout" + uses: actions/checkout@v2 with: fetch-depth: 0 - - name: 'Install Flutter' - run: ./.github/workflows/scripts/install-flutter.sh stable - - name: 'Install Tools' + - name: Cache Flutter dependencies + uses: actions/cache@v2 + with: + path: /opt/hostedtoolcache/flutter + key: ${{ env.flutter_version }}-flutter + - name: "Install Flutter" + uses: subosito/flutter-action@v1 + with: + flutter-version: ${{ env.flutter_version }} + - name: "Install Tools" + run: flutter pub global activate melos + - name: "Bootstrap Workspace" + run: melos bootstrap + - name: "Melos Format" + run: melos run format + - name: "Validate Formatting" run: | - ./.github/workflows/scripts/install-tools.sh - - name: 'Bootstrap Workspace' - run: melos bootstrap --verbose - - name: 'Dart' - run: | - melos exec -c 1 -- \ - flutter format . ./.github/workflows/scripts/validate-formatting.sh test: runs-on: macos-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v2 + - name: "Git Checkout" + uses: actions/checkout@v2 with: fetch-depth: 0 - - name: 'Install Flutter' - run: ./.github/workflows/scripts/install-flutter.sh stable - - name: 'Install Tools' + - name: Cache Flutter dependencies + uses: actions/cache@v2 + with: + path: /Users/runner/hostedtoolcache/flutter + key: ${{ env.flutter_version }}-flutter + - name: "Install Flutter" + uses: subosito/flutter-action@v1 + with: + flutter-version: ${{ env.flutter_version }} + - name: "Install Tools" run: | - ./.github/workflows/scripts/install-tools.sh - flutter pub global activate coverage - flutter pub global activate remove_from_coverage - - name: 'Bootstrap Workspace' - run: melos bootstrap --verbose - - name: 'Dart Test' - run: | - cd packages/stream_chat - flutter pub run test --coverage coverage/ - format_coverage --lcov --in=coverage/ --out=coverage/lcov.info --packages=.packages --report-on=lib - - name: 'Flutter Test' - run: | - melos exec -c 3 --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - flutter test --coverage - - name: CodeCov - run: | - melos exec -c 3 --fail-fast --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - "\$MELOS_ROOT_PATH/.github/workflows/scripts/coverage.sh" - bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + flutter pub global activate melos + pub global activate remove_from_coverage + - name: "Bootstrap Workspace" + run: melos bootstrap + - name: "Flutter Test" + run: melos run test:all + - name: "Collect Coverage" + run: melos run coverage:ignore-file --no-select + - name: "Upload Coverage" + uses: codecov/codecov-action@v1 + with: + token: ${{secrets.CODECOV_TOKEN}} + files: packages/*/coverage/lcov.info + - name: "Stream Chat Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat/coverage/lcov.info min_coverage: 40 - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + - name: "Stream Chat Persistence Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_persistence/coverage/lcov.info min_coverage: 95 - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + - name: "Stream Chat Flutter Core Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter_core/coverage/lcov.info min_coverage: 90 - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + - name: "Stream Chat Flutter Coverage Check" + uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter/coverage/lcov.info - min_coverage: 35 + min_coverage: 35 \ No newline at end of file diff --git a/packages/stream_chat_persistence/analysis_options.yaml b/analysis_options.yaml similarity index 95% rename from packages/stream_chat_persistence/analysis_options.yaml rename to analysis_options.yaml index f0a87ea5..4b73086c 100644 --- a/packages/stream_chat_persistence/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,12 +1,10 @@ analyzer: - enable-experiment: - - extension-methods exclude: - - lib/**/*.g.dart - - example/** - - lib/src/emoji - - lib/**/*.freezed.dart - - test/** + - packages/*/lib/**/*.g.dart + - packages/*/example/** + - packages/*/lib/src/emoji + - packages/*/lib/**/*.freezed.dart + - packages/*/test/** linter: rules: @@ -147,4 +145,4 @@ linter: - cast_nullable_to_non_nullable - unnecessary_null_checks - tighten_type_of_initializing_formals - - null_check_on_nullable_type_parameter + - null_check_on_nullable_type_parameter \ No newline at end of file diff --git a/melos.yaml b/melos.yaml index 3fcad03f..6232c3fa 100644 --- a/melos.yaml +++ b/melos.yaml @@ -1,4 +1,4 @@ -name: stream_chat_dart +name: stream_chat_flutter versioning: mode: independent @@ -7,56 +7,68 @@ packages: - packages/** scripts: + lint:all: + run: melos run analyze && melos run format + description: Run all static analysis checks - # - Requires `pub global activate tuneup`. - analyze: > - melos exec -c 1 --fail-fast -- \ - pub global run tuneup check + analyze: + run: | + melos exec -c 4 --ignore="*example*" -- \ + dart analyze --fatal-infos . + description: | + Run `dart analyze` in all packages. + - Note: you can also rely on your IDEs Dart Analysis / Issues window. - format: pub global run flutter_plugin_tools format + format: + run: flutter format --set-exit-if-changed . + description: | + Run `flutter format --set-exit-if-changed .` in all packages. + lint:pub: + run: | + melos exec -c 4 --no-private --ignore="*example*" -- \ + pub publish --dry-run + description: | + Run `pub publish --dry-run` in all packages. + - Note: you can also rely on your IDEs Dart Analysis / Issues window. - build:examples:ios: > - melos exec -c 1 --scope="*example*" --fail-fast -- \ - flutter build ios --no-codesign + generate:all: + run: melos run generate:dart && melos run generate:flutter + description: Build all generated files for Dart & Flutter packages in this project. + generate:dart: + run: melos exec -c 1 --depends-on="build_runner" --no-flutter -- "dart run build_runner build --delete-conflicting-outputs" + description: Build all generated files for Dart packages in this project. - build:examples:android: > - melos exec -c 1 --scope="*example*" --fail-fast -- \ - flutter build apk + generate:flutter: + run: melos exec -c 1 --depends-on="build_runner" --flutter -- "flutter pub run build_runner build --delete-conflicting-outputs" + description: Build all generated files for Flutter packages in this project. - # Build any plugin example apps that have MacOS support. - # - Requires `flutter config --enable-macos-desktop` enabled. - # - Requires `flutter channel master && flutter upgrade`. - build:examples:macos: > - melos exec -c 1 --scope="*example*" --dir-exists=macos --fail-fast -- \ - flutter build macos + test:all: + run: melos run test:dart --no-select && melos run test:flutter --no-select + description: Run all Dart & Flutter tests in this project. + test:dart: + run: melos exec -c 1 --fail-fast -- "flutter test --coverage" + description: Run Dart tests for a specific package in this project. + select-package: + flutter: false + dir-exists: test - test:dart: > - melos exec -c 1 --fail-fast --no-flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - flutter pub run test + test:flutter: + run: melos exec -c 3 --fail-fast -- "flutter test --coverage" + description: Run Flutter tests for a specific package in this project. + select-package: + flutter: true + dir-exists: test - test:flutter: > - melos exec -c 1 --fail-fast --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ - flutter test - - test:web: > - melos exec -c 1 --fail-fast --dir-exists=test --scope="*web*" -- \ - flutter test --platform=chrome - - - lint:pub: > - melos exec -c 5 --fail-fast --no-private --ignore="*example*" -- \ - pub publish --dry-run - - - postclean: > - melos exec -- \ - rm -rf ./build ./android/.gradle ./ios/.symlinks ./ios/Pods ./android/.idea ./.idea ./.dart-tool/build - -dev_dependencies: - pedantic: 1.9.2 + coverage:ignore-file: + run: | + melos exec -c 4 --fail-fast -- "\$MELOS_ROOT_PATH/.github/workflows/scripts/remove-from-coverage.sh" + description: Removes all the ignored files from the coverage report. + select-package: + dir-exists: coverage environment: - sdk: ">=2.12.0 <3.0.0" \ No newline at end of file + sdk: '>=2.12.0 <3.0.0' + flutter: '>=1.22.4 <2.0.0' \ No newline at end of file diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 75fecc34..b82d3025 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,54 @@ +## 2.0.0-nullsafety.7 + +đŸ›‘ī¸ Breaking Changes from `2.0.0-nullsafety.6` + +- `ConnectUserWithProvider` now requires `tokenProvider` as a required param. (Removed from the constructor) +- `client.disconnect()` is now divided into two different functions + - `client.closeConnection()` -> for closing user websocket connection. + - `client.disconnectUser()` -> for disconnecting user and resetting client state. +- `client.devToken()` now returns a `Token` model instead of `String`. +- `ApiError` is removed in favor of `StreamChatError` + - `StreamChatError` -> parent type for all the stream errors. + - `StreamWebSocketError` -> for user websocket related errors. + - `StreamChatNetworkError` -> for network related errors. +- `client.queryChannels()`, `channel.query()` options param is removed in favor of individual params + - `option.state` -> bool state + - `option.watch` -> bool watch + - `option.presence` -> bool presence +- `client.queryUsers()` options param is removed in favor of individual params + - `option.presence` -> bool presence + +✅ Added + +- New `Location` enum is introduced for easily changing the client location/baseUrl. +- New `client.openConnection()` and `client.closeConnection()` is introduced to connect/disconnect user ws connection. + +🔄 Changed + +- `baseURL` is now deprecated in favor of using `Location` to change data location. + +🐞 Fixed + +- [#369](https://github.com/GetStream/stream-chat-flutter/issues/369): Client does not return without internet + connection + +## 2.0.0-nullsafety.6 + +- Fix thread reply not working with attachments +- Minor fixes + +## 2.0.0-nullsafety.6 + +- Fix thread reply not working with attachments +- Minor fixes + +## 2.0.0-nullsafety.5 + +- Minor fixes +- Performance improvements +- Fixed `skip_push` in `client.sendMessage` +- Added partial message update method + ## 2.0.0-nullsafety.2 - Added new `Filter.raw` constructor @@ -45,7 +96,8 @@ - Save pinned messages in offline storage - Minor fixes - `StreamClient.QueryChannels` now returns a Stream and fetches the channels from storage before calling the api -- Added `StreamClient.QueryChannelsOnline` and `StreamClient.QueryChannelsOffline` to fetch channels only from online or offline +- Added `StreamClient.QueryChannelsOnline` and `StreamClient.QueryChannelsOffline` to fetch channels only from online or + offline ## 1.2.0-beta @@ -56,7 +108,8 @@ ## 1.1.0-beta - Fixed minor bugs -- Add support for custom attachment upload [docs here](https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart) +- Add support for custom attachment + upload [docs here](https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart) - Add support for asynchronous attachment upload ## 1.0.3-beta @@ -66,7 +119,8 @@ ## 1.0.2-beta -- Deprecated `setUser`, `setGuestUser`, `setUserWithProvider` in favor of `connectUser`, `connectGuestUser`, `connectUserWithProvider` +- Deprecated `setUser`, `setGuestUser`, `setUserWithProvider` in favor of `connectUser`, `connectGuestUser` + , `connectUserWithProvider` - Optimised reaction updates - i.e., Update first call Api later. ## 1.0.1-beta @@ -76,9 +130,11 @@ ## 1.0.0-beta - 🛑 **BREAKING** Renamed `Client` to less generic `StreamChatClient` -- 🛑 **BREAKING** Segregated the persistence layer into separate package [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) +- 🛑 **BREAKING** Segregated the persistence layer into separate + package [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) - 🛑 **BREAKING** Moved `Client.backgroundKeepAlive` to [core package](https://pub.dev/packages/stream_chat_core) -- 🛑 **BREAKING** Moved `Client.showLocalNotification` to [core package](https://pub.dev/packages/stream_chat_core) and renamed it to `StreamChatCore.onBackgroundEventReceived` +- 🛑 **BREAKING** Moved `Client.showLocalNotification` to [core package](https://pub.dev/packages/stream_chat_core) and + renamed it to `StreamChatCore.onBackgroundEventReceived` - Removed `flutter` dependency. This is now a pure Dart package đŸĨŗ - Minor improvements and bugfixes @@ -133,7 +189,8 @@ ## 0.2.20 -- Return offline data only if the backend is unreachable. This avoids the glitch of the ChannelListView because we cannot sort by custom properties. +- Return offline data only if the backend is unreachable. This avoids the glitch of the ChannelListView because we + cannot sort by custom properties. ## 0.2.19 @@ -147,7 +204,7 @@ ## 0.2.17+1 -- Do not retry messages when server returns error +- Do not retry messages when server returns error ## 0.2.17 diff --git a/packages/stream_chat/analysis_options.yaml b/packages/stream_chat/analysis_options.yaml deleted file mode 100644 index f0a87ea5..00000000 --- a/packages/stream_chat/analysis_options.yaml +++ /dev/null @@ -1,150 +0,0 @@ -analyzer: - enable-experiment: - - extension-methods - exclude: - - lib/**/*.g.dart - - example/** - - lib/src/emoji - - lib/**/*.freezed.dart - - test/** - -linter: - rules: - # these rules are documented on and in the same order as - # the Dart Lint rules page to make maintenance easier - # https://github.com/dart-lang/linter/blob/master/example/all.yaml - - always_use_package_imports - - avoid_empty_else - - avoid_relative_lib_imports - - avoid_slow_async_io - - avoid_types_as_parameter_names - - cancel_subscriptions - - close_sinks - - control_flow_in_finally - - empty_statements - - hash_and_equals - - invariant_booleans - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - literal_only_boolean_expressions - - no_adjacent_strings_in_list - - no_duplicate_case_values - - no_logic_in_create_state - - prefer_void_to_null - - test_types_in_equals - - throw_in_finally - - unnecessary_statements - - unrelated_type_equality_checks - - omit_local_variable_types - - use_key_in_widget_constructors - - valid_regexps - - always_declare_return_types - - always_require_non_null_named_parameters - - annotate_overrides - - avoid_bool_literals_in_conditional_expressions - - avoid_catching_errors - - avoid_init_to_null - - avoid_null_checks_in_equality_operators - - avoid_positional_boolean_parameters - - avoid_private_typedef_functions - - avoid_redundant_argument_values - - avoid_return_types_on_setters - - avoid_returning_null_for_void - - avoid_shadowing_type_parameters - - avoid_single_cascade_in_expression_statements - - avoid_unnecessary_containers - - avoid_unused_constructor_parameters - - await_only_futures - - camel_case_extensions - - camel_case_types - - cascade_invocations - - - constant_identifier_names - - curly_braces_in_flow_control_structures - - directives_ordering - - empty_catches - - empty_constructor_bodies - - exhaustive_cases - - file_names - - implementation_imports - - join_return_with_assignment - - leading_newlines_in_multiline_strings - - library_names - - library_prefixes - - lines_longer_than_80_chars - - missing_whitespace_between_adjacent_strings - - non_constant_identifier_names - - null_closures - - one_member_abstracts - - only_throw_errors - - package_api_docs - - package_prefixed_library_names - - parameter_assignments - - prefer_adjacent_string_concatenation - - prefer_asserts_in_initializer_lists - - prefer_asserts_with_message - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - prefer_constructors_over_static_methods - - prefer_contains - - prefer_equal_for_default_values - - prefer_expression_function_bodies - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - - prefer_int_literals - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - prefer_is_not_empty - - prefer_is_not_operator - - prefer_null_aware_operators - - prefer_single_quotes - - prefer_spread_collections - - prefer_typing_uninitialized_variables - - provide_deprecation_message - - public_member_api_docs - - recursive_getters - - sized_box_for_whitespace - - slash_for_doc_comments - - sort_child_properties_last - - sort_constructors_first - - sort_unnamed_constructors_first - - - type_annotate_public_apis - - type_init_formals - - unnecessary_await_in_return - - unnecessary_brace_in_string_interps - - unnecessary_const - - unnecessary_getters_setters - - unnecessary_lambdas - - unnecessary_new - - unnecessary_null_aware_assignments - - unnecessary_null_in_if_null_operators - - unnecessary_nullable_for_final_variable_declarations - - unnecessary_parenthesis - - unnecessary_raw_strings - - unnecessary_string_escapes - - unnecessary_string_interpolations - - unnecessary_this - - use_is_even_rather_than_modulo - - use_late_for_private_fields_and_variables - - use_rethrow_when_possible - - use_setters_to_change_properties - - use_to_and_as_if_applicable - - package_names - - sort_pub_dependencies - - - cast_nullable_to_non_nullable - - unnecessary_null_checks - - tighten_type_of_initializing_formals - - null_check_on_nullable_type_parameter diff --git a/packages/stream_chat/lib/src/api/retry_policy.dart b/packages/stream_chat/lib/src/api/retry_policy.dart deleted file mode 100644 index f0f2c67b..00000000 --- a/packages/stream_chat/lib/src/api/retry_policy.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/exceptions.dart'; - -/// The retry options -class RetryPolicy { - /// Instantiate a new RetryPolicy - RetryPolicy({ - required this.shouldRetry, - required this.retryTimeout, - this.attempt = 0, - }); - - /// The number of attempts tried so far - int attempt = 0; - - /// This function evaluates if we should retry the failure - final bool Function(StreamChatClient client, int attempt, ApiError? apiError) - shouldRetry; - - /// In the case that we want to retry a failed request the retryTimeout - /// method is called to determine the timeout - final Duration Function( - StreamChatClient client, int attempt, ApiError? apiError) retryTimeout; - - /// Creates a copy of [RetryPolicy] with specified attributes overridden. - RetryPolicy copyWith({ - bool Function(StreamChatClient client, int attempt, ApiError? apiError)? - shouldRetry, - Duration Function(StreamChatClient client, int attempt, ApiError? apiError)? - retryTimeout, - int? attempt, - }) => - RetryPolicy( - retryTimeout: retryTimeout ?? this.retryTimeout, - shouldRetry: shouldRetry ?? this.shouldRetry, - attempt: attempt ?? this.attempt, - ); -} diff --git a/packages/stream_chat/lib/src/api/retry_queue.dart b/packages/stream_chat/lib/src/api/retry_queue.dart deleted file mode 100644 index c13db9c6..00000000 --- a/packages/stream_chat/lib/src/api/retry_queue.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:logging/logging.dart'; -import 'package:stream_chat/src/api/channel.dart'; -import 'package:stream_chat/src/api/retry_policy.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/exceptions.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/stream_chat.dart'; - -/// The retry queue associated to a channel -class RetryQueue { - /// Instantiate a new RetryQueue object - RetryQueue({ - required this.channel, - this.logger, - }) { - _retryPolicy = channel.client.retryPolicy; - - _listenConnectionRecovered(); - - _listenFailedEvents(); - } - - /// The channel of this queue - final Channel channel; - - /// The logger associated to this queue - final Logger? logger; - - final _subscriptions = []; - - void _listenConnectionRecovered() { - _subscriptions - .add(channel.client.on(EventType.connectionRecovered).listen((event) { - if (!_isRetrying && event.online!) { - _startRetrying(); - } - })); - } - - final HeapPriorityQueue _messageQueue = HeapPriorityQueue(_byDate); - bool _isRetrying = false; - RetryPolicy? _retryPolicy; - - /// Add a list of messages - void add(List messages) { - logger?.info('added ${messages.length} messages'); - final messageList = _messageQueue.toList(); - - _messageQueue.addAll(messages - .where((element) => !messageList.any((m) => m.id == element.id))); - - if (_messageQueue.isNotEmpty && !_isRetrying) { - _startRetrying(); - } - } - - Future _startRetrying() async { - logger?.info('start retrying'); - _isRetrying = true; - final retryPolicy = _retryPolicy!.copyWith(attempt: 0); - - while (_messageQueue.isNotEmpty) { - final message = _messageQueue.first; - try { - logger?.info('retry attempt ${retryPolicy.attempt}'); - await _sendMessage(message); - logger?.info('message sent - removing it from the queue'); - _messageQueue.remove(message); - logger?.info('now ${_messageQueue.length} messages in the queue'); - retryPolicy.attempt = 0; - } catch (error) { - ApiError? apiError; - if (error is DioError) { - if (error.type == DioErrorType.response) { - _messageQueue.remove(message); - return; - } - apiError = ApiError( - error.response?.data, - error.response?.statusCode, - ); - } else if (error is ApiError) { - apiError = error; - if (apiError.status?.toString().startsWith('4') == true) { - _messageQueue.remove(message); - return; - } - } - - if (!retryPolicy.shouldRetry( - channel.client, - retryPolicy.attempt, - apiError, - )) { - _messageQueue.toList().forEach(_sendFailedEvent); - _isRetrying = false; - return; - } - - retryPolicy.attempt++; - - final timeout = retryPolicy.retryTimeout( - channel.client, - retryPolicy.attempt, - apiError, - ); - await Future.delayed(timeout); - } - } - _isRetrying = false; - } - - void _sendFailedEvent(Message? message) { - final newStatus = message!.status == MessageSendingStatus.sending - ? MessageSendingStatus.failed - : (message.status == MessageSendingStatus.updating - ? MessageSendingStatus.failed_update - : MessageSendingStatus.failed_delete); - channel.state!.addMessage(message.copyWith( - status: newStatus, - )); - } - - Future _sendMessage(Message message) async { - if (message.status == MessageSendingStatus.failed_update || - message.status == MessageSendingStatus.updating) { - await channel.updateMessage(message); - } else if (message.status == MessageSendingStatus.failed || - message.status == MessageSendingStatus.sending) { - await channel.sendMessage(message); - } else if (message.status == MessageSendingStatus.failed_delete || - message.status == MessageSendingStatus.deleting) { - await channel.deleteMessage(message); - } - } - - void _listenFailedEvents() { - _subscriptions.add(channel.on().listen((event) { - final messageList = _messageQueue.toList(); - if (event.message != null) { - final messageIndex = - messageList.indexWhere((m) => m.id == event.message!.id); - if (messageIndex == -1 && - [ - MessageSendingStatus.failed_update, - MessageSendingStatus.failed, - MessageSendingStatus.failed_delete, - ].contains(event.message!.status)) { - logger?.info('add message from events'); - final m = event.message; - - if (m != null) { - add([m]); - } - } else if (messageIndex != -1 && - [ - MessageSendingStatus.sent, - null, - ].contains(event.message!.status)) { - _messageQueue.remove(messageList[messageIndex]); - } - } - })); - } - - /// Call this method to dispose this object - void dispose() { - _messageQueue.clear(); - _subscriptions.forEach((s) => s.cancel()); - } - - static int _byDate(Message m1, Message m2) { - final date1 = _getMessageDate(m1); - final date2 = _getMessageDate(m2); - - if (date1 == null || date2 == null) { - return 0; - } - - return date1.compareTo(date2); - } - - static DateTime? _getMessageDate(Message m1) { - switch (m1.status) { - case MessageSendingStatus.failed_delete: - case MessageSendingStatus.deleting: - return m1.deletedAt; - - case MessageSendingStatus.failed: - case MessageSendingStatus.sending: - return m1.createdAt; - - case MessageSendingStatus.failed_update: - case MessageSendingStatus.updating: - return m1.updatedAt; - default: - return null; - } - } -} diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_html.dart b/packages/stream_chat/lib/src/api/web_socket_channel_html.dart deleted file mode 100644 index ab821ca9..00000000 --- a/packages/stream_chat/lib/src/api/web_socket_channel_html.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:web_socket_channel/html.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Html version of websocket implementation -/// Used in Flutter web version -WebSocketChannel connectWebSocket(String url, {Iterable? protocols}) => - HtmlWebSocketChannel.connect(url, protocols: protocols); diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_io.dart b/packages/stream_chat/lib/src/api/web_socket_channel_io.dart deleted file mode 100644 index 8402ca0c..00000000 --- a/packages/stream_chat/lib/src/api/web_socket_channel_io.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:web_socket_channel/io.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// IO version of websocket implementation -/// Used in Flutter mobile version -WebSocketChannel connectWebSocket(String url, {Iterable? protocols}) => - IOWebSocketChannel.connect(url, protocols: protocols); diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart b/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart deleted file mode 100644 index e2efaee6..00000000 --- a/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Stub version of websocket implementation -/// Used just for conditional library import -WebSocketChannel connectWebSocket(String url, - {Iterable? protocols, - Map? headers, - Duration? pingInterval}) => - throw UnimplementedError(); diff --git a/packages/stream_chat/lib/src/api/websocket.dart b/packages/stream_chat/lib/src/api/websocket.dart deleted file mode 100644 index 657aa125..00000000 --- a/packages/stream_chat/lib/src/api/websocket.dart +++ /dev/null @@ -1,321 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:logging/logging.dart'; -import 'package:meta/meta.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/api/connection_status.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Typedef which exposes an [Event] as the only parameter. -typedef EventHandler = void Function(Event); - -/// Typedef used for connecting to a websocket. Method returns a -/// [WebSocketChannel] and accepts a connection [url] and an optional -/// [Iterable] of `protocols`. -typedef ConnectWebSocket = WebSocketChannel Function(String? url, - {Iterable? protocols}); - -// TODO: parse error even -// TODO: if parsing an error into an event fails we should not hide the -// TODO: original error -/// A WebSocket connection that reconnects upon failure. -class WebSocket { - /// Creates a new websocket - /// To connect the WS call [connect] - WebSocket({ - required this.baseUrl, - required this.user, - required this.handler, - this.connectParams = const {}, - this.connectPayload = const {}, - this.logger, - this.connectFunc, - this.reconnectionMonitorInterval = 1, - this.healthCheckInterval = 20, - this.reconnectionMonitorTimeout = 40, - }) { - final qs = Map.from(connectParams); - - final data = Map.from(connectPayload); - - data['user_details'] = user.toJson(); - qs['json'] = json.encode(data); - - if (baseUrl.startsWith('https')) { - _path = baseUrl.replaceFirst('https://', ''); - _path = Uri.https(_path, 'connect', qs) - .toString() - .replaceFirst('https', 'wss'); - } else if (baseUrl.startsWith('http')) { - _path = baseUrl.replaceFirst('http://', ''); - _path = - Uri.http(_path, 'connect', qs).toString().replaceFirst('http', 'ws'); - } else { - _path = Uri.https(baseUrl, 'connect', qs) - .toString() - .replaceFirst('https', 'wss'); - } - } - - /// WS base url - final String baseUrl; - - /// User performing the WS connection - final User user; - - /// Querystring connection parameters - final Map connectParams; - - /// WS connection payload - final Map connectPayload; - - /// Functions that will be called every time a new event is received from the - /// connection - final EventHandler handler; - - /// A WS specific logger instance - final Logger? logger; - - /// Connection function - /// Used only for testing purpose - @visibleForTesting - final ConnectWebSocket? connectFunc; - - /// Interval of the reconnection monitor timer - /// This checks that it received a new event in the last - /// [reconnectionMonitorTimeout] seconds, otherwise it considers the - /// connection unhealthy and reconnects the WS - final int reconnectionMonitorInterval; - - /// Interval of the health event sending timer - /// This sends a health event every [healthCheckInterval] seconds in order to - /// make the server aware that the client is still listening - final int healthCheckInterval; - - /// The timeout that uses the reconnection monitor timer to consider the - /// connection unhealthy - final int reconnectionMonitorTimeout; - - final BehaviorSubject _connectionStatusController = - BehaviorSubject.seeded(ConnectionStatus.disconnected); - - set _connectionStatus(ConnectionStatus status) => - _connectionStatusController.add(status); - - /// The current connection status value - ConnectionStatus? get connectionStatus => _connectionStatusController.value; - - /// This notifies of connection status changes - Stream get connectionStatusStream => - _connectionStatusController.stream; - - late String _path; - int _retryAttempt = 1; - late WebSocketChannel _channel; - Timer? _healthCheck, _reconnectionMonitor; - DateTime? _lastEventAt; - bool _manuallyDisconnected = false; - bool _connecting = false; - bool _reconnecting = false; - - Event _decodeEvent(String source) => Event.fromJson(json.decode(source)); - - Completer _connectionCompleter = Completer(); - - /// Connect the WS using the parameters passed in the constructor - Future connect() async { - _manuallyDisconnected = false; - - if (_connecting) { - logger?.severe('already connecting'); - return null; - } - - _connecting = true; - _connectionStatus = ConnectionStatus.connecting; - - logger?.info('connecting to $_path'); - - _channel = - connectFunc?.call(_path) ?? WebSocketChannel.connect(Uri.parse(_path)); - _channel.stream.listen( - (data) async { - final jsonData = json.decode(data); - if (jsonData['error'] != null) { - return _onConnectionError(jsonData['error']); - } - _onData(data); - }, - onError: (error, stacktrace) { - _onConnectionError(error, stacktrace); - }, - onDone: _onDone, - ); - return _connectionCompleter.future; - } - - void _onDone() { - _connecting = false; - if (_manuallyDisconnected) { - return; - } - - logger?.info('connection closed | closeCode: ${_channel.closeCode} | ' - 'closedReason: ${_channel.closeReason}'); - - if (!_reconnecting) { - _reconnect(); - } - } - - void _onData(data) { - if (_manuallyDisconnected) { - return; - } - - final event = _decodeEvent(data); - logger?.info('received new event: $data'); - - if (_lastEventAt == null) { - logger?.info('connection estabilished'); - _connecting = false; - _reconnecting = false; - _lastEventAt = DateTime.now(); - - _connectionStatus = ConnectionStatus.connected; - _retryAttempt = 1; - - if (!_connectionCompleter.isCompleted) { - _connectionCompleter.complete(event); - } - - _startReconnectionMonitor(); - _startHealthCheck(); - } - - handler(event); - _lastEventAt = DateTime.now(); - } - - Future _onConnectionError(error, [stacktrace]) async { - logger?..severe('error connecting')..severe(error); - if (stacktrace != null) { - logger?.severe(stacktrace); - } - _connecting = false; - - if (!_reconnecting) { - _connectionStatus = ConnectionStatus.disconnected; - } - - if (!_connectionCompleter.isCompleted) { - _cancelTimers(); - _connectionCompleter.completeError(error, stacktrace); - } else if (!_reconnecting) { - return _reconnect(); - } - } - - void _reconnectionTimer(_) { - final now = DateTime.now(); - if (_lastEventAt != null && - now.difference(_lastEventAt!).inSeconds > reconnectionMonitorTimeout) { - _channel.sink.close(); - } - } - - void _startReconnectionMonitor() { - _reconnectionMonitor = Timer.periodic( - Duration(seconds: reconnectionMonitorInterval), - _reconnectionTimer, - ); - - _reconnectionTimer(_reconnectionMonitor); - } - - void _reconnectTimer() async { - if (!_reconnecting) { - return; - } - if (_connecting) { - logger?.info('already connecting'); - return; - } - - logger?.info('reconnecting..'); - - _cancelTimers(); - - try { - await connect(); - } catch (e) { - logger?.log(Level.SEVERE, e.toString()); - } - await Future.delayed( - Duration(seconds: min(_retryAttempt * 5, 25)), - () { - _reconnectTimer(); - _retryAttempt++; - }, - ); - } - - Future _reconnect() async { - logger?.info('reconnect'); - if (!_reconnecting) { - _reconnecting = true; - _connectionStatus = ConnectionStatus.connecting; - } - - _reconnectTimer(); - } - - void _cancelTimers() { - _lastEventAt = null; - if (_healthCheck != null) { - _healthCheck!.cancel(); - } - if (_reconnectionMonitor != null) { - _reconnectionMonitor!.cancel(); - } - } - - void _healthCheckTimer(_) { - logger?.info('sending health.check'); - _channel.sink.add("{'type': 'health.check'}"); - } - - void _startHealthCheck() { - logger?.info('start health check monitor'); - - _healthCheck = Timer.periodic( - Duration(seconds: healthCheckInterval), - _healthCheckTimer, - ); - - _healthCheckTimer(_healthCheck); - } - - /// Disconnects the WS and releases eventual resources - Future disconnect() async { - _connecting = false; - if (!_connectionCompleter.isCompleted) { - _connectionCompleter.complete(); - } - if (_manuallyDisconnected) { - return; - } - logger?.info('disconnecting'); - _connectionCompleter = Completer(); - _cancelTimers(); - _reconnecting = false; - _manuallyDisconnected = true; - _connectionStatus = ConnectionStatus.disconnected; - await _connectionStatusController.close(); - await _channel.sink.close(); - } -} diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart deleted file mode 100644 index de199b84..00000000 --- a/packages/stream_chat/lib/src/client.dart +++ /dev/null @@ -1,1571 +0,0 @@ -// ignore_for_file: unnecessary_getters_setters - -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:logging/logging.dart'; -import 'package:meta/meta.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/api/channel.dart'; -import 'package:stream_chat/src/api/connection_status.dart'; -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/api/responses.dart'; -import 'package:stream_chat/src/api/retry_policy.dart'; -import 'package:stream_chat/src/api/websocket.dart'; -import 'package:stream_chat/src/attachment_file_uploader.dart'; -import 'package:stream_chat/src/db/chat_persistence_client.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/exceptions.dart'; -import 'package:stream_chat/src/extensions/map_extension.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/filter.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/own_user.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; -import 'package:stream_chat/version.dart'; -import 'package:uuid/uuid.dart'; - -/// Handler function used for logging records. Function requires a single -/// [LogRecord] as the only parameter. -typedef LogHandlerFunction = void Function(LogRecord record); - -/// Used for decoding [Map] data to a generic type `T`. -typedef DecoderFunction = T Function(Map); - -/// A function which can be used to request a Stream Chat API token from your -/// own backend server. Function requires a single [userId]. -typedef TokenProvider = Future Function(String? userId); - -/// Provider used to send push notifications. -enum PushProvider { - /// Send notifications using Google's Firebase Cloud Messaging - firebase, - - /// Send notifications using Apple's Push Notification service - apn -} - -extension on PushProvider { - /// Returns the string notion for [PushProvider]. - String get name { - if (this == PushProvider.apn) { - return 'apn'; - } else { - return 'firebase'; - } - } -} - -/// The official Dart client for Stream Chat, -/// a service for building chat applications. -/// This library can be used on any Dart project and on both mobile and web apps -/// with Flutter. -/// -/// You can sign up for a Stream account at https://getstream.io/chat/ -/// -/// The Chat client will manage API call, event handling and manage the -/// websocket connection to Stream Chat servers. -/// -/// ```dart -/// final client = StreamChatClient("stream-chat-api-key"); -/// ``` -class StreamChatClient { - /// Create a client instance with default options. - /// You should only create the client once and re-use it across your - /// application. - StreamChatClient( - this.apiKey, { - this.tokenProvider, - this.baseURL = _defaultBaseURL, - this.logLevel = Level.WARNING, - LogHandlerFunction? logHandlerFunction, - Duration connectTimeout = const Duration(seconds: 6), - Duration receiveTimeout = const Duration(seconds: 6), - Dio? httpClient, - RetryPolicy? retryPolicy, - this.attachmentFileUploader, - }) { - _retryPolicy = retryPolicy ?? - RetryPolicy( - retryTimeout: - (StreamChatClient client, int attempt, ApiError? error) => - Duration(seconds: 1 * attempt), - shouldRetry: - (StreamChatClient client, int attempt, ApiError? error) => - attempt < 5, - ); - - attachmentFileUploader ??= StreamAttachmentFileUploader(this); - - state = ClientState(this); - - _setupLogger(logHandlerFunction); - _setupDio(httpClient, receiveTimeout, connectTimeout); - - logger.info('instantiating new client'); - } - - set chatPersistenceClient(ChatPersistenceClient? value) { - _originalChatPersistenceClient = value; - } - - ChatPersistenceClient? _originalChatPersistenceClient; - - /// Chat persistence client - ChatPersistenceClient? get chatPersistenceClient => _chatPersistenceClient; - - ChatPersistenceClient? _chatPersistenceClient; - - /// Attachment uploader - AttachmentFileUploader? attachmentFileUploader; - - /// Whether the chat persistence is available or not - bool get persistenceEnabled => _chatPersistenceClient != null; - - RetryPolicy? _retryPolicy; - - bool _synced = false; - - /// The retry policy options getter - RetryPolicy? get retryPolicy => _retryPolicy; - - /// This client state - late ClientState state; - - /// By default the Chat client will write all messages with level Warn or - /// Error to stdout. - /// - /// During development you might want to enable more logging information, - /// you can change the default log level when constructing the client. - /// - /// ```dart - /// final client = StreamChatClient("stream-chat-api-key", - /// logLevel: Level.INFO); - /// ``` - final Level logLevel; - - /// Client specific logger instance. - /// Refer to the class [Logger] to learn more about the specific - /// implementation. - final Logger logger = Logger.detached('📡'); - - /// A function that has a parameter of type [LogRecord]. - /// This is called on every new log record. - /// By default the client will use the handler returned by - /// [_getDefaultLogHandler]. - /// Setting it you can handle the log messages directly instead of have them - /// written to stdout, - /// this is very convenient if you use an error tracking tool or if you want - /// to centralize your logs into one facility. - /// - /// ```dart - /// myLogHandlerFunction = (LogRecord record) { - /// // do something with the record (ie. send it to Sentry or Fabric) - /// } - /// - /// final client = StreamChatClient("stream-chat-api-key", - /// logHandlerFunction: myLogHandlerFunction); - ///``` - late LogHandlerFunction logHandlerFunction; - - /// Your project Stream Chat api key. - /// Find your API keys here https://getstream.io/dashboard/ - String apiKey; - - /// Your project Stream Chat base url. - final String baseURL; - - /// A function in which you send a request to your own backend to get a Stream - /// Chat API token. - /// - /// The token will be the return value of the function. - /// It's used by the client to refresh the token once expired or to connect - /// the user without a predefined token using [connectUserWithProvider]. - final TokenProvider? tokenProvider; - - /// [Dio] httpClient - /// It's be chosen because it's easy to use and supports interesting features - /// out of the box (Interceptors, Global configuration, FormData, - /// File downloading etc.) - @visibleForTesting - Dio httpClient = Dio(); - - static const _defaultBaseURL = 'chat-us-east-1.stream-io-api.com'; - static const _tokenExpiredErrorCode = 40; - StreamSubscription? _connectionStatusSubscription; - Future Function(ConnectionStatus)? _connectionStatusHandler; - - final BehaviorSubject _controller = BehaviorSubject(); - - /// Stream of [Event] coming from websocket connection - /// Listen to this or use the [on] method to filter specific event types - Stream get stream => _controller.stream; - - final _wsConnectionStatusController = - BehaviorSubject.seeded(ConnectionStatus.disconnected); - - set _wsConnectionStatus(ConnectionStatus status) => - _wsConnectionStatusController.add(status); - - /// The current status value of the websocket connection - ConnectionStatus? get wsConnectionStatus => - _wsConnectionStatusController.value; - - /// This notifies the connection status of the websocket connection. - /// Listen to this to get notified when the websocket tries to reconnect. - Stream get wsConnectionStatusStream => - _wsConnectionStatusController.stream; - - /// The current user token - String? token; - - /// The id of the current websocket connection - String? get connectionId => _connectionId; - - bool _anonymous = false; - String? _connectionId; - late WebSocket _ws; - - bool get _hasConnectionId => _connectionId != null; - - void _setupDio( - Dio? httpClient, - Duration receiveTimeout, - Duration connectTimeout, - ) { - logger.info('http client setup'); - - this.httpClient = httpClient ?? Dio(); - - String url; - if (!baseURL.startsWith('https') && !baseURL.startsWith('http')) { - url = Uri.https(baseURL, '').toString(); - } else { - url = baseURL; - } - - this.httpClient.options.baseUrl = url; - this.httpClient.options.receiveTimeout = receiveTimeout.inMilliseconds; - this.httpClient.options.connectTimeout = connectTimeout.inMilliseconds; - this.httpClient.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) async { - options.queryParameters.addAll(_commonQueryParams); - options.headers.addAll(_httpHeaders); - - if (_connectionId != null && - (options.data is Map || options.data == null)) { - options.data = { - 'connection_id': _connectionId, - ...options.data ?? {}, - }; - } - - var stringData = options.data.toString(); - - if (options.data is FormData) { - final multiPart = (options.data as FormData).files[0].value; - stringData = '${multiPart.filename} - ${multiPart.contentType}'; - } - - logger.info(''' - - method: ${options.method} - url: ${options.uri} - headers: ${options.headers} - data: $stringData - - '''); - handler.next(options); - }, - onError: _tokenExpiredInterceptor, - ), - ); - } - - Future _tokenExpiredInterceptor( - DioError err, - ErrorInterceptorHandler handler, - ) async { - final apiError = ApiError( - err.response?.data, - err.response?.statusCode, - ); - - if (apiError.code == _tokenExpiredErrorCode) { - logger.info('token expired'); - - if (tokenProvider != null) { - httpClient.lock(); - final userId = state.user!.id; - - await _disconnect(); - - final newToken = await tokenProvider!(userId); - await Future.delayed(const Duration(seconds: 4)); - token = newToken; - - httpClient.unlock(); - - await connectUser(User(id: userId), newToken); - - try { - return handler.resolve( - await httpClient.request( - err.requestOptions.path, - cancelToken: err.requestOptions.cancelToken, - data: err.requestOptions.data, - onReceiveProgress: err.requestOptions.onReceiveProgress, - onSendProgress: err.requestOptions.onSendProgress, - queryParameters: err.requestOptions.queryParameters, - options: Options( - method: err.requestOptions.method, - sendTimeout: err.requestOptions.sendTimeout, - receiveTimeout: err.requestOptions.receiveTimeout, - extra: err.requestOptions.extra, - headers: err.requestOptions.headers, - responseType: err.requestOptions.responseType, - contentType: err.requestOptions.contentType, - validateStatus: err.requestOptions.validateStatus, - receiveDataWhenStatusError: - err.requestOptions.receiveDataWhenStatusError, - followRedirects: err.requestOptions.followRedirects, - maxRedirects: err.requestOptions.maxRedirects, - requestEncoder: err.requestOptions.requestEncoder, - responseDecoder: err.requestOptions.responseDecoder, - listFormat: err.requestOptions.listFormat, - ), - ), - ); - } on DioError { - return handler.reject(err); - } - } - } - - return handler.next(err); - } - - LogHandlerFunction _getDefaultLogHandler() { - final levelEmojiMapper = { - Level.INFO.name: 'â„šī¸', - Level.WARNING.name: 'âš ī¸', - Level.SEVERE.name: '🚨', - }; - return (LogRecord record) { - print( - '(${record.time}) ' - '${levelEmojiMapper[record.level.name] ?? record.level.name} ' - '${record.loggerName} ${record.message}', - ); - if (record.stackTrace != null) { - print(record.stackTrace); - } - }; - } - - Logger _detachedLogger( - String name, - ) => - Logger.detached(name) - ..level = logLevel - ..onRecord.listen(logHandlerFunction); - - void _setupLogger(LogHandlerFunction? logHandlerFunction) { - logger.level = logLevel; - - this.logHandlerFunction = logHandlerFunction ?? _getDefaultLogHandler(); - - logger.onRecord.listen(this.logHandlerFunction); - - logger.info('logger setup'); - } - - /// Call this function to dispose the client - void dispose() async { - await _chatPersistenceClient?.disconnect(); - await _disconnect(); - httpClient.close(); - await _controller.close(); - state.dispose(); - await _wsConnectionStatusController.close(); - } - - Map get _httpHeaders => { - 'Authorization': token, - 'stream-auth-type': _authType, - 'X-Stream-Client': _userAgent, - 'Content-Encoding': 'gzip', - }; - - /// Set the current user, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - @Deprecated('Use `connectUser` instead. Will be removed in Future releases') - Future setUser(User user, String token) => connectUser(user, token); - - /// Connects the current user, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - Future connectUser(User? user, String? token) async { - if (_connectCompleter != null && !_connectCompleter!.isCompleted) { - logger.warning('Already connecting'); - throw Exception('Already connecting'); - } - - _connectCompleter = Completer(); - - logger.info('connect user'); - - if (user == null) { - final e = Error(); - _connectCompleter! - .completeError(e, StackTrace.fromString('No user provided.')); - throw e; - } - - state.user = OwnUser.fromJson(user.toJson()); - this.token = token; - _anonymous = false; - - return connect().then((event) { - _connectCompleter!.complete(event); - return event; - }).catchError((e, s) { - _connectCompleter!.completeError(e, s); - throw e; - }); - } - - /// Set the current user using the [tokenProvider] to fetch the token. - /// It returns a [Future] that resolves when the connection is setup. - @Deprecated( - 'Use `connectUserWithProvider` instead. Will be removed in Future releases', - ) - Future setUserWithProvider(User user) => - connectUserWithProvider(user); - - /// Connects the current user using the [tokenProvider] to fetch the token. - /// It returns a [Future] that resolves when the connection is setup. - Future connectUserWithProvider(User user) async { - if (tokenProvider == null) { - throw Exception(''' - TokenProvider must be provided in the constructor in order to use `connectUserWithProvider` method. - Use `connectUser` providing a token. - '''); - } - final token = await tokenProvider!(user.id); - return connectUser(user, token); - } - - /// Stream of [Event] coming from websocket connection - /// Pass an eventType as parameter in order to filter just a type of event - Stream on([ - String? eventType, - String? eventType2, - String? eventType3, - String? eventType4, - ]) => - stream.where((event) => - eventType == null || - (event.type != null && - (event.type == eventType || - event.type == eventType2 || - event.type == eventType3 || - event.type == eventType4))); - - /// Method called to add a new event to the [_controller]. - void handleEvent(Event event) async { - logger.info('handle new event: ${event.toJson()}'); - if (event.connectionId != null) { - _connectionId = event.connectionId; - } - - if (!event.isLocal) { - final createdAt = event.createdAt; - if (_synced && createdAt != null) { - await _chatPersistenceClient?.updateConnectionInfo(event); - await _chatPersistenceClient?.updateLastSyncAt(createdAt); - } - } - - if (event.user != null) { - state._updateUser(event.user); - } - - if (event.me != null) { - state.user = event.me; - } - _controller.add(event); - } - - Completer? _connectCompleter; - - /// Connect the client websocket - Future connect() async { - logger.info('connecting'); - if (wsConnectionStatus == ConnectionStatus.connecting) { - logger.warning('Already connecting'); - throw Exception('Already connecting'); - } - - if (wsConnectionStatus == ConnectionStatus.connected) { - logger.warning('Already connected'); - throw Exception('Already connected'); - } - - _wsConnectionStatus = ConnectionStatus.connecting; - - if (_originalChatPersistenceClient != null) { - _chatPersistenceClient = _originalChatPersistenceClient; - await _chatPersistenceClient!.connect(state.user!.id); - } - - _ws = WebSocket( - baseUrl: baseURL, - user: state.user!, - connectParams: { - 'api_key': apiKey, - 'authorization': token!, - 'stream-auth-type': _authType, - 'X-Stream-Client': _userAgent, - }, - connectPayload: { - 'user_id': state.user!.id, - 'server_determines_connection_id': true, - }, - handler: handleEvent, - logger: _detachedLogger('🔌'), - ); - - _connectionStatusHandler = (ConnectionStatus status) async { - _wsConnectionStatus = status; - handleEvent( - Event( - type: EventType.connectionChanged, - online: status == ConnectionStatus.connected, - ), - ); - - if (status == ConnectionStatus.connected) { - handleEvent(const Event( - type: EventType.connectionRecovered, - online: true, - )); - if (state.channels.isNotEmpty == true) { - // ignore: unawaited_futures - queryChannelsOnline( - filter: Filter.in_('cid', state.channels.keys.toList()), - ).then( - (_) async { - await resync(); - }, - ); - } else { - _synced = false; - } - } - }; - - _connectionStatusSubscription = - _ws.connectionStatusStream.listen(_connectionStatusHandler); - - var event = await _chatPersistenceClient?.getConnectionInfo(); - - await _ws.connect().then((e) async { - if (e != null) { - _chatPersistenceClient?.updateConnectionInfo(e); - event = e; - } - resync(); - }).catchError((err, stacktrace) { - logger.severe('error connecting ws', err, stacktrace); - if (err is Map) { - // ignore: only_throw_errors - throw err; - } - }); - - return event; - } - - /// Get the events missed while offline to sync the offline storage - Future resync([List? cids]) async { - final lastSyncAt = await _chatPersistenceClient?.getLastSyncAt(); - - if (lastSyncAt == null) { - _synced = true; - return; - } - - cids ??= await _chatPersistenceClient?.getChannelCids(); - - if (cids?.isEmpty == true) { - return; - } - - try { - final rawRes = await post('/sync', data: { - 'channel_cids': cids, - 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), - }); - logger.fine('rawRes: $rawRes'); - - final res = decode( - rawRes.data, - SyncResponse.fromJson, - ); - - res.events.sort((a, b) => a.createdAt!.compareTo(b.createdAt!)); - - res.events.forEach((element) { - logger - ..fine('element.type: ${element.type}') - ..fine('element.message.text: ${element.message?.text}'); - }); - - res.events.forEach(handleEvent); - - await _chatPersistenceClient?.updateLastSyncAt(DateTime.now()); - _synced = true; - } catch (error) { - logger.severe('Error during resync $error'); - } - } - - String? _asMap(sort) => sort?.map((s) => s.toJson().toString())?.join(''); - - final _queryChannelsStreams = >>{}; - - /// Requests channels with a given query. - Stream> queryChannels({ - Filter? filter, - List>? sort, - Map? options, - PaginationParams paginationParams = const PaginationParams(), - int? messageLimit, - bool waitForConnect = true, - }) async* { - final hash = base64.encode(utf8.encode( - '$filter${_asMap(sort)}$options${paginationParams.toJson()}' - '$messageLimit', - )); - - if (_queryChannelsStreams.containsKey(hash)) { - yield await _queryChannelsStreams[hash]!; - } else { - final channels = await queryChannelsOffline( - filter: filter, - sort: sort, - paginationParams: paginationParams, - ); - if (channels.isNotEmpty) yield channels; - - try { - final newQueryChannelsFuture = queryChannelsOnline( - filter: filter, - sort: sort, - options: options, - paginationParams: paginationParams, - messageLimit: messageLimit, - waitForConnect: waitForConnect, - ).whenComplete(() { - _queryChannelsStreams.remove(hash); - }); - - _queryChannelsStreams[hash] = newQueryChannelsFuture; - - yield await newQueryChannelsFuture; - } catch (_) { - if (channels.isEmpty) rethrow; - } - } - } - - /// Requests channels with a given query from the API. - Future> queryChannelsOnline({ - Filter? filter, - List>? sort, - Map? options, - int? messageLimit, - PaginationParams paginationParams = const PaginationParams(), - bool waitForConnect = true, - }) async { - if (waitForConnect) { - if (_connectCompleter != null && !_connectCompleter!.isCompleted) { - logger.info('awaiting connection completer'); - await _connectCompleter!.future; - } - if (wsConnectionStatus != ConnectionStatus.connected) { - throw Exception( - 'You cannot use queryChannels without an active connection.' - ' Please call `connectUser` to connect the client.', - ); - } - } - - logger.info('Query channel start'); - final defaultOptions = { - 'state': true, - 'watch': true, - 'presence': false, - }; - - final payload = { - 'filter_conditions': filter, - 'sort': sort, - }; - - if (messageLimit != null) { - payload['message_limit'] = messageLimit; - } - - payload.addAll(defaultOptions); - - if (options != null) { - payload.addAll(options); - } - - payload.addAll(paginationParams.toJson()); - - final response = await get( - '/channels', - queryParameters: { - 'payload': jsonEncode(payload), - }, - ); - - final res = decode( - response.data, - QueryChannelsResponse.fromJson, - ); - - if (res.channels.isEmpty && paginationParams.offset == 0) { - logger.warning( - ''' - We could not find any channel for this query. - Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial - If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart''', - ); - return []; - } - - final channels = res.channels; - - final users = channels - .expand((it) => it.members) - .map((it) => it.user) - .toList(growable: false); - - state._updateUsers(users); - - logger.info('Got ${res.channels.length} channels from api'); - - final updateData = _mapChannelStateToChannel(channels); - - await _chatPersistenceClient?.updateChannelQueries( - filter, - channels.map((c) => c.channel!.cid).toList(), - clearQueryCache: paginationParams.offset == 0, - ); - - state.channels = updateData.key; - return updateData.value; - } - - /// Requests channels with a given query from the Persistence client. - Future> queryChannelsOffline({ - Filter? filter, - List>? sort, - PaginationParams paginationParams = const PaginationParams(), - }) async { - final offlineChannels = (await _chatPersistenceClient?.getChannelStates( - filter: filter, - sort: sort, - paginationParams: paginationParams, - )) ?? - []; - final updatedData = _mapChannelStateToChannel(offlineChannels); - state.channels = updatedData.key; - return updatedData.value; - } - - MapEntry, List> _mapChannelStateToChannel( - List channelStates, - ) { - final channels = {...state.channels}; - final newChannels = []; - for (final channelState in channelStates) { - final channel = channels[channelState.channel!.cid]; - if (channel != null) { - channel.state?.updateChannelState(channelState); - newChannels.add(channel); - } else { - final newChannel = Channel.fromState(this, channelState); - if (newChannel.cid != null) { - channels[newChannel.cid!] = newChannel; - } - newChannels.add(newChannel); - } - } - return MapEntry(channels, newChannels); - } - - Object _parseError(DioError error) { - if (error.type == DioErrorType.response) { - final apiError = - ApiError(error.response?.data, error.response?.statusCode); - logger.severe('apiError: ${apiError.toString()}'); - return apiError; - } - - return error; - } - - /// Handy method to make http GET request with error parsing. - Future> get( - String path, { - Map? queryParameters, - }) async { - try { - final response = await httpClient.get( - path, - queryParameters: queryParameters, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http POST request with error parsing. - Future> post( - String path, { - dynamic data, - ProgressCallback? onSendProgress, - CancelToken? cancelToken, - }) async { - try { - final response = await httpClient.post( - path, - data: data, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http DELETE request with error parsing. - Future> delete( - String path, { - Map? queryParameters, - CancelToken? cancelToken, - }) async { - try { - final response = await httpClient.delete( - path, - queryParameters: queryParameters, - cancelToken: cancelToken, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http PATCH request with error parsing. - Future> patch( - String path, { - Map? queryParameters, - dynamic data, - }) async { - try { - final response = await httpClient.patch( - path, - queryParameters: queryParameters, - data: data, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Handy method to make http PUT request with error parsing. - Future> put( - String path, { - Map? queryParameters, - dynamic data, - }) async { - try { - final response = await httpClient.put( - path, - queryParameters: queryParameters, - data: data, - ); - return response; - } on DioError catch (error) { - // ignore: only_throw_errors - throw _parseError(error); - } - } - - /// Used to log errors and stacktrace in case of bad json deserialization - T decode(String? j, DecoderFunction decoderFunction) { - try { - final data = j ?? '{}'; - return decoderFunction(json.decode(data)); - } catch (error, stacktrace) { - logger.severe('Error decoding response', error, stacktrace); - rethrow; - } - } - - String get _authType => _anonymous ? 'anonymous' : 'jwt'; - - String get _userAgent => 'stream-chat-dart-client-${CurrentPlatform.name}-' - '${PACKAGE_VERSION.split('+')[0]}'; - - Map get _commonQueryParams => { - 'user_id': state.user?.id, - 'api_key': apiKey, - 'connection_id': _connectionId, - }; - - /// Set the current user with an anonymous id, this triggers a connection to - /// the API. It returns a [Future] that resolves when the connection is setup. - @Deprecated( - 'Use `connectAnonymousUser` instead. Will be removed in Future releases') - Future setAnonymousUser() => connectAnonymousUser(); - - /// Connects the current user with an anonymous id, this triggers a connection - /// to the API. It returns a [Future] that resolves when the connection is - /// setup. - Future connectAnonymousUser() async { - if (_connectCompleter != null && !_connectCompleter!.isCompleted) { - logger.warning('Already connecting'); - throw Exception('Already connecting'); - } - - _connectCompleter = Completer(); - - _anonymous = true; - const uuid = Uuid(); - state.user = OwnUser(id: uuid.v4()); - - return connect().then((event) { - _connectCompleter!.complete(event); - return event; - }).catchError((e, s) { - _connectCompleter!.completeError(e, s); - throw e; - }); - } - - /// Set the current user as guest, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - @Deprecated( - 'Use `connectGuestUser` instead. Will be removed in Future releases') - Future setGuestUser(User user) => connectGuestUser(user); - - /// Connects the current user as guest, this triggers a connection to the API. - /// It returns a [Future] that resolves when the connection is setup. - Future connectGuestUser(User user) async { - _anonymous = true; - final response = await post('/guest', data: {'user': user.toJson()}) - .then((res) => decode( - res.data, ConnectGuestUserResponse.fromJson)) - .whenComplete(() => _anonymous = false); - - return connectUser( - response.user, - response.accessToken, - ); - } - - /// Closes the websocket connection and resets the client - /// If [flushChatPersistence] is true the client deletes all offline - /// user's data. If [clearUser] is true the client unsets the current user - Future disconnect({ - bool flushChatPersistence = false, - bool clearUser = false, - }) async { - logger.info('Disconnecting flushOfflineStorage: $flushChatPersistence; ' - 'clearUser: $clearUser'); - - await _chatPersistenceClient?.disconnect(flush: flushChatPersistence); - _chatPersistenceClient = null; - - _connectCompleter = null; - - if (clearUser == true) { - state.dispose(); - state = ClientState(this); - } - - await _disconnect(); - } - - Future _disconnect() async { - logger.info('Client disconnecting'); - - await _ws.disconnect(); - await _connectionStatusSubscription?.cancel(); - } - - /// Requests users with a given query. - Future queryUsers({ - Filter? filter, - List? sort, - Map? options, - PaginationParams? pagination, - }) async { - final defaultOptions = { - 'presence': _hasConnectionId, - }; - - final payload = { - 'filter_conditions': filter, - 'sort': sort, - }..addAll(defaultOptions); - - if (pagination != null) { - payload.addAll(pagination.toJson()); - } - - if (options != null) { - payload.addAll(options); - } - - final rawRes = await get( - '/users', - queryParameters: { - 'payload': jsonEncode(payload), - }, - ); - - final response = decode( - rawRes.data, - QueryUsersResponse.fromJson, - ); - - state._updateUsers(response.users); - - return response; - } - - /// A message search. - Future search( - Filter filter, { - String? query, - List? sort, - PaginationParams? paginationParams, - Filter? messageFilters, - }) async { - assert(() { - if (query == null && messageFilters == null) { - throw ArgumentError('Provide at least `query` or `messageFilters`'); - } - if (query != null && messageFilters != null) { - throw ArgumentError( - "Can't provide both `query` and `messageFilters` at the same time", - ); - } - return true; - }(), 'Check incoming params.'); - - final payload = { - 'filter_conditions': filter, - 'message_filter_conditions': messageFilters, - 'query': query, - 'sort': sort, - if (paginationParams != null) ...paginationParams.toJson(), - }.nullProtected; - - final response = await get('/search', queryParameters: { - 'payload': json.encode(payload), - }); - - return decode( - response.data, SearchMessagesResponse.fromJson); - } - - /// Send a [file] to the [channelId] of type [channelType] - Future sendFile( - AttachmentFile file, - String channelId, - String channelType, { - ProgressCallback? onSendProgress, - CancelToken? cancelToken, - }) => - attachmentFileUploader!.sendFile( - file, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - - /// Send a [image] to the [channelId] of type [channelType] - Future sendImage( - AttachmentFile image, - String channelId, - String channelType, { - ProgressCallback? onSendProgress, - CancelToken? cancelToken, - }) => - attachmentFileUploader!.sendImage( - image, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - ); - - /// Delete a file from this channel - Future deleteFile( - String url, - String channelId, - String channelType, { - CancelToken? cancelToken, - }) => - attachmentFileUploader!.deleteFile( - url, - channelId, - channelType, - cancelToken: cancelToken, - ); - - /// Delete an image from this channel - Future deleteImage( - String url, - String channelId, - String channelType, { - CancelToken? cancelToken, - }) => - attachmentFileUploader!.deleteImage( - url, - channelId, - channelType, - cancelToken: cancelToken, - ); - - /// Add a device for Push Notifications. - Future addDevice(String id, PushProvider pushProvider) async { - final response = await post('/devices', data: { - 'id': id, - 'push_provider': pushProvider.name, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Gets a list of user devices. - Future getDevices() async { - final response = await get('/devices'); - return decode( - response.data, ListDevicesResponse.fromJson); - } - - /// Remove a user's device. - Future removeDevice(String id) async { - final response = await delete('/devices', queryParameters: { - 'id': id, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Get a development token - String devToken(String userId) { - final payload = json.encode({'user_id': userId}); - final payloadBytes = utf8.encode(payload); - final payloadB64 = base64.encode(payloadBytes); - return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$payloadB64.devtoken'; - } - - /// Returns a channel client with the given type, id and custom data. - Channel channel( - String type, { - String? id, - Map extraData = const {}, - }) { - if (id != null && state.channels.containsKey('$type:$id')) { - return state.channels['$type:$id']!; - } - return Channel(this, type, id, extraData: extraData); - } - - /// Update or Create the given user object. - Future updateUser(User user) async => - updateUsers([user]); - - /// Batch update a list of users - Future updateUsers(List users) async { - final response = await post('/users', data: { - 'users': users.asMap().map((_, u) => MapEntry(u.id, u.toJson())), - }); - return decode( - response.data, - UpdateUsersResponse.fromJson, - ); - } - - /// Bans a user from all channels - Future banUser( - String targetUserID, [ - Map options = const {}, - ]) async { - final data = Map.from(options) - ..addAll({ - 'target_user_id': targetUserID, - }); - final response = await post( - '/moderation/ban', - data: data, - ); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Remove global ban for a user - Future unbanUser( - String targetUserID, [ - Map options = const {}, - ]) async { - final data = Map.from(options) - ..addAll({ - 'target_user_id': targetUserID, - }); - final response = await delete( - '/moderation/ban', - queryParameters: data, - ); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Shadow bans a user - Future shadowBan( - String targetID, [ - Map options = const {}, - ]) async => - banUser(targetID, { - 'shadow': true, - ...options, - }); - - /// Removes shadow ban from a user - Future removeShadowBan( - String targetID, [ - Map options = const {}, - ]) async => - unbanUser(targetID, { - 'shadow': true, - ...options, - }); - - /// Mutes a user - Future muteUser(String targetID) async { - final response = await post('/moderation/mute', data: { - 'target_id': targetID, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Unmutes a user - Future unmuteUser(String targetID) async { - final response = await post('/moderation/unmute', data: { - 'target_id': targetID, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Flag a message - Future flagMessage(String messageID) async { - final response = await post('/moderation/flag', data: { - 'target_message_id': messageID, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Unflag a message - Future unflagMessage(String messageId) async { - final response = await post('/moderation/unflag', data: { - 'target_message_id': messageId, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Flag a user - Future flagUser(String userId) async { - final response = await post('/moderation/flag', data: { - 'target_user_id': userId, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Unflag a message - Future unflagUser(String userId) async { - final response = await post('/moderation/unflag', data: { - 'target_user_id': userId, - }); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Mark all channels for this user as read - Future markAllRead() async { - final response = await post('/channels/read'); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Sends the message to the given channel - Future sendMessage( - Message message, - String channelId, - String channelType, { - bool skipPush = false, - }) async { - final response = await post( - '/channels/$channelType/$channelId/message', - data: { - 'message': message.toJson(), - 'skip_push': skipPush, - }, - ); - return decode(response.data, SendMessageResponse.fromJson); - } - - /// Update the given message - Future updateMessage(Message message) async { - final response = await post( - '/messages/${message.id}', - data: {'message': message.toJson()}, - ); - return decode(response.data, UpdateMessageResponse.fromJson); - } - - /// Partially update the given message - /// Use 'set' in map to set values - /// User 'unset' in map to unset values - Future partiallyUpdateMessage( - String id, - Map data, - ) async { - final response = await put( - '/messages/$id', - data: data, - ); - return decode(response.data, UpdateMessageResponse.fromJson); - } - - /// Deletes the given message - Future deleteMessage(Message message) async { - final response = await delete('/messages/${message.id}'); - return decode(response.data, EmptyResponse.fromJson); - } - - /// Get a message by id - Future getMessage(String messageId) async { - final response = await get('/messages/$messageId'); - return decode(response.data, GetMessageResponse.fromJson); - } - - /// Pins provided message - /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds - /// to be added to [DateTime.now] - Future pinMessage( - Message message, [ - Object? timeoutOrExpirationDate, - ]) { - assert(() { - if (timeoutOrExpirationDate is! DateTime && - timeoutOrExpirationDate is! num && - timeoutOrExpirationDate != null) { - throw ArgumentError('Invalid timeout or Expiration date'); - } - return true; - }(), 'Check whether time out is valid'); - - DateTime? pinExpires; - if (timeoutOrExpirationDate is DateTime) { - pinExpires = timeoutOrExpirationDate.toUtc(); - } else if (timeoutOrExpirationDate is num) { - pinExpires = DateTime.now() - .add( - Duration(seconds: timeoutOrExpirationDate.toInt()), - ) - .toUtc(); - } - return partiallyUpdateMessage(message.id, { - 'set': { - 'pinned': true, - if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(), - } - }); - } - - /// Unpins provided message - Future unpinMessage(Message message) => - partiallyUpdateMessage(message.id, { - 'set': { - 'pinned': false, - } - }); -} - -/// The class that handles the state of the channel listening to the events -class ClientState { - /// Creates a new instance listening to events and updating the state - ClientState(this._client) { - _subscriptions.addAll([ - _client - .on() - .where((event) => event.me != null) - .map((e) => e.me) - .listen((user) { - _userController.add(user); - if (user?.totalUnreadCount != null) { - _totalUnreadCountController.add(user?.totalUnreadCount); - } - - if (user?.unreadChannels != null) { - _unreadChannelsController.add(user?.unreadChannels); - } - }), - _client - .on() - .where((event) => event.unreadChannels != null) - .map((e) => e.unreadChannels) - .listen(_unreadChannelsController.add), - _client - .on() - .where((event) => event.totalUnreadCount != null) - .map((e) => e.totalUnreadCount) - .listen(_totalUnreadCountController.add), - ]); - - _listenChannelDeleted(); - - _listenChannelHidden(); - - _listenUserUpdated(); - } - - final _subscriptions = []; - - /// Used internally for optimistic update of unread count - set totalUnreadCount(int? unreadCount) { - _totalUnreadCountController.add(unreadCount ?? 0); - } - - void _listenChannelHidden() { - _subscriptions.add(_client.on(EventType.channelHidden).listen((event) { - final cid = event.cid; - - if (cid != null) { - _client.chatPersistenceClient?.deleteChannels([cid]); - } - channels = channels..removeWhere((cid, ch) => cid == event.cid); - })); - } - - void _listenUserUpdated() { - _subscriptions.add(_client.on(EventType.userUpdated).listen((event) { - if (event.user!.id == user!.id) { - user = OwnUser.fromJson(event.user!.toJson()); - } - _updateUser(event.user); - })); - } - - void _listenChannelDeleted() { - _subscriptions.add(_client - .on( - EventType.channelDeleted, - EventType.notificationRemovedFromChannel, - EventType.notificationChannelDeleted, - ) - .listen((Event event) async { - final eventChannel = event.channel!; - await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - channels = channels..remove(eventChannel.cid); - })); - } - - final StreamChatClient _client; - - /// Update user information - set user(OwnUser? user) { - _userController.add(user); - } - - void _updateUsers(List userList) { - final newUsers = { - ...users, - for (var user in userList) user!.id: user, - }; - _usersController.add(newUsers); - } - - void _updateUser(User? user) => _updateUsers([user]); - - /// The current user - OwnUser? get user => _userController.valueOrNull; - - /// The current user as a stream - Stream get userStream => _userController.stream; - - /// The current user - Map get users => _usersController.value; - - /// The current user as a stream - Stream> get usersStream => _usersController.stream; - - /// The current unread channels count - int? get unreadChannels => _unreadChannelsController.valueOrNull; - - /// The current unread channels count as a stream - Stream get unreadChannelsStream => _unreadChannelsController.stream; - - /// The current total unread messages count - int? get totalUnreadCount => _totalUnreadCountController.valueOrNull; - - /// The current total unread messages count as a stream - Stream get totalUnreadCountStream => _totalUnreadCountController.stream; - - /// The current list of channels in memory as a stream - Stream?> get channelsStream => - _channelsController.stream; - - /// The current list of channels in memory - Map get channels => _channelsController.value; - - set channels(Map v) { - _channelsController.add(v); - } - - final BehaviorSubject> _channelsController = - BehaviorSubject.seeded({}); - final BehaviorSubject _userController = BehaviorSubject(); - final BehaviorSubject> _usersController = - BehaviorSubject.seeded({}); - final BehaviorSubject _unreadChannelsController = BehaviorSubject(); - final BehaviorSubject _totalUnreadCountController = BehaviorSubject(); - - /// Call this method to dispose this object - void dispose() { - _subscriptions.forEach((s) => s.cancel()); - _userController.close(); - _unreadChannelsController.close(); - _totalUnreadCountController.close(); - channels.values.forEach((c) => c.dispose()); - _channelsController.close(); - } -} diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/client/channel.dart similarity index 80% rename from packages/stream_chat/lib/src/api/channel.dart rename to packages/stream_chat/lib/src/client/channel.dart index afb92711..eee18356 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1,18 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart' show IterableExtension, ListEquality; import 'package:dio/dio.dart'; -import 'package:logging/logging.dart'; +import 'package:rate_limiter/rate_limiter.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/api/retry_queue.dart'; +import 'package:stream_chat/src/client/retry_queue.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/extensions/rate_limit.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/stream_chat.dart'; /// This a the class that manages a specific channel. @@ -22,9 +22,9 @@ class Channel { this._client, this._type, this._id, { - Map extraData = const {}, + Map? extraData, }) : _cid = _id != null ? '$_type:$_id' : null, - _extraData = extraData { + _extraData = extraData ?? {} { _client.logger.info('New Channel instance not initialized created'); } @@ -51,9 +51,9 @@ class Channel { String? _id; String? _cid; - final Map _extraData; + final Map _extraData; - set extraData(Map extraData) { + set extraData(Map extraData) { if (_initializedCompleter.isCompleted) { throw StateError( 'Once the channel is initialized you should use channel.update ' @@ -70,8 +70,11 @@ class Channel { true; /// Returns true if the channel is muted as a stream - Stream? get isMutedStream => _client.state.userStream.map((event) => - event!.channelMutes.any((element) => element.channel.cid == cid) == true); + Stream? get isMutedStream => _client.state.userStream + .map((event) => + event!.channelMutes.any((element) => element.channel.cid == cid) == + true) + .distinct(); /// True if the channel is a group bool get isGroup => memberCount != 2; @@ -199,8 +202,13 @@ class Channel { } /// Channel extra data - Map get extraData => - state?._channelState.channel?.extraData ?? _extraData; + Map get extraData { + var data = state?._channelState.channel?.extraData; + if (data == null || data.isEmpty) { + data = _extraData; + } + return data; + } /// Channel extra data as a stream Stream> get extraDataStream { @@ -214,8 +222,6 @@ class Channel { StreamChatClient get client => _client; final StreamChatClient _client; - String get _channelURL => '/channels/$type/$id'; - final Completer _initializedCompleter = Completer(); /// True if this is initialized @@ -236,12 +242,14 @@ class Channel { }) { final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; if (cancelToken == null) { - throw Exception( - "Upload request for this Attachment hasn't started yet or else " + throw const StreamChatError( + "Upload request for this Attachment hasn't started yet or maybe " 'Already completed', ); } - if (cancelToken.isCancelled) throw Exception('Already cancelled'); + if (cancelToken.isCancelled) { + throw const StreamChatError('Upload request already cancelled'); + } cancelToken.cancel(reason); } @@ -253,12 +261,15 @@ class Channel { String messageId, Iterable attachmentIds, ) { - final message = state!.messages.firstWhereOrNull( + final message = [ + ...state!.messages, + ...state!.threads.values.expand((messages) => messages), + ].firstWhereOrNull( (it) => it.id == messageId, ); if (message == null) { - throw Exception('Error, Message not found'); + throw const StreamChatError('Error, Message not found'); } final attachments = message.attachments.where((it) { @@ -390,7 +401,6 @@ class Channel { _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - // ignore: unawaited_futures _uploadAttachments( message.id, message.attachments.map((it) => it.id), @@ -408,9 +418,9 @@ class Channel { ); state!.addMessage(response.message); return response; - } catch (error) { - if (error is DioError && error.type != DioErrorType.response) { - state!.retryQueue?.add([message]); + } catch (e) { + if (e is StreamChatNetworkError && e.isRetriable) { + state!._retryQueue.add([message]); } rethrow; } @@ -420,8 +430,7 @@ class Channel { /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. Future updateMessage(Message message) async { - final currentMessage = - state?.messages.firstWhere((e) => e.id == message.id); + final originalMessage = message; // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. @@ -444,12 +453,11 @@ class Channel { state?.addMessage(message); try { - if (message.attachments.any((it) => !it.uploadState.isSuccess) == true) { + if (message.attachments.any((it) => !it.uploadState.isSuccess)) { final attachmentsUploadCompleter = Completer(); _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - // ignore: unawaited_futures _uploadAttachments( message.id, message.attachments.map((it) => it.id), @@ -468,12 +476,12 @@ class Channel { state?.addMessage(m); return response; - } catch (error) { - if (error is DioError && error.type != DioErrorType.response) { - state?.retryQueue?.add([message]); - } else if (error is ApiError) { - if (currentMessage != null) { - state?.addMessage(currentMessage); + } catch (e) { + if (e is StreamChatNetworkError) { + if (e.isRetriable) { + state!._retryQueue.add([message]); + } else { + state?.addMessage(originalMessage); } } rethrow; @@ -481,21 +489,30 @@ class Channel { } /// Partially updates the [message] in this channel. - Future partiallyUpdateMessage( - Message message, Map data) async { + /// Use [set] to define values to be set + /// Use [unset] to define values to be unset + Future partialUpdateMessage( + Message message, { + Map? set, + List? unset, + }) async { try { - final response = await _client.partiallyUpdateMessage(message.id, data); + final response = await _client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ); - final m = response.message.copyWith( + final updatedMessage = response.message.copyWith( ownReactions: message.ownReactions, ); - state?.addMessage(m); + state?.addMessage(updatedMessage); return response; - } catch (error) { - if (error is DioError && error.type != DioErrorType.response) { - state?.retryQueue?.add([message]); + } catch (e) { + if (e is StreamChatNetworkError && e.isRetriable) { + state!._retryQueue.add([message]); } rethrow; } @@ -529,14 +546,14 @@ class Channel { state?.addMessage(message); - final response = await _client.deleteMessage(message); + final response = await _client.deleteMessage(message.id); state?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); return response; - } catch (error) { - if (error is DioError && error.type != DioErrorType.response) { - state?.retryQueue?.add([message]); + } catch (e) { + if (e is StreamChatNetworkError && e.isRetriable) { + state!._retryQueue.add([message]); } rethrow; } @@ -544,9 +561,9 @@ class Channel { /// Pins provided message Future pinMessage( - Message message, [ - Object? timeoutOrExpirationDate, - ]) { + Message message, { + Object? /*num|DateTime*/ timeoutOrExpirationDate, + }) { assert(() { if (timeoutOrExpirationDate is! DateTime && timeoutOrExpirationDate != null && @@ -554,7 +571,7 @@ class Channel { throw ArgumentError('Invalid timeout or Expiration date'); } return true; - }(), 'Check whether timeout is valid'); + }(), 'Check for invalid timeout or expiration date'); DateTime? pinExpires; if (timeoutOrExpirationDate is DateTime) { @@ -564,21 +581,23 @@ class Channel { Duration(seconds: timeoutOrExpirationDate.toInt()), ); } - return partiallyUpdateMessage(message, { - 'set': { + return partialUpdateMessage( + message, + set: { 'pinned': true, - if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(), - } - }); + 'pin_expires': pinExpires?.toUtc().toIso8601String(), + }, + ); } /// Unpins provided message Future unpinMessage(Message message) => - partiallyUpdateMessage(message, { - 'set': { + partialUpdateMessage( + message, + set: { 'pinned': false, - } - }); + }, + ); /// Send a file to this channel Future sendFile( @@ -660,10 +679,7 @@ class Channel { /// Send an event on this channel Future sendEvent(Event event) { _checkInitialized(); - return _client.post( - '$_channelURL/event', - data: {'event': event.toJson()}, - ).then((res) => _client.decode(res.data, EmptyResponse.fromJson)!); + return _client.sendEvent(id!, type, event); } /// Send a reaction to this channel @@ -715,21 +731,13 @@ class Channel { state?.addMessage(newMessage); - final data = Map.from(extraData) - ..addAll({ - 'type': type, - }); - try { - final res = await _client.post( - '/messages/$messageId/reaction', - data: { - 'reaction': data, - 'enforce_unique': enforceUnique, - }, + final reactionResp = await _client.sendReaction( + messageId, + type, + extraData: extraData, + enforceUnique: enforceUnique, ); - final reactionResp = - _client.decode(res.data, SendReactionResponse.fromJson); return reactionResp; } catch (_) { // Reset the message if the update fails @@ -772,9 +780,11 @@ class Channel { state?.addMessage(newMessage); try { - final res = await client - .delete('/messages/${message.id}/reaction/${reaction.type}'); - return _client.decode(res.data, EmptyResponse.fromJson); + final deleteResponse = await _client.deleteReaction( + message.id, + reaction.type, + ); + return deleteResponse; } catch (_) { // Reset the message if the update fails state?.addMessage(message); @@ -784,48 +794,49 @@ class Channel { /// Edit the channel custom data Future update( - Map channelData, [ + Map channelData, [ Message? updateMessage, ]) async { - final response = await _client.post(_channelURL, data: { - if (updateMessage != null) - 'message': updateMessage.copyWith(updatedAt: DateTime.now()).toJson(), - 'data': channelData, - }); - return _client.decode(response.data, UpdateChannelResponse.fromJson); + _checkInitialized(); + return _client.updateChannel( + id!, + type, + channelData, + message: updateMessage, + ); } /// Edit the channel custom data - Future updatePartial( - Map channelData) async { - final response = await _client.patch(_channelURL, data: channelData); - return _client.decode(response.data, PartialUpdateChannelResponse.fromJson); + Future updatePartial({ + Map? set, + List? unset, + }) async { + _checkInitialized(); + return _client.updateChannelPartial(id!, type, set: set, unset: unset); } /// Delete this channel. Messages are permanently removed. Future delete() async { - final response = await _client.delete(_channelURL); - return _client.decode(response.data, EmptyResponse.fromJson); + _checkInitialized(); + return _client.deleteChannel(id!, type); } /// Removes all messages from the channel Future truncate() async { - final response = await _client.post('$_channelURL/truncate'); - return _client.decode(response.data, EmptyResponse.fromJson); + _checkInitialized(); + return _client.truncateChannel(id!, type); } /// Accept invitation to the channel Future acceptInvite([Message? message]) async { - final res = await _client.post(_channelURL, - data: {'accept_invite': true, 'message': message?.toJson()}); - return _client.decode(res.data, AcceptInviteResponse.fromJson); + _checkInitialized(); + return _client.acceptChannelInvite(id!, type, message: message); } /// Reject invitation to the channel Future rejectInvite([Message? message]) async { - final res = await _client.post(_channelURL, - data: {'reject_invite': true, 'message': message?.toJson()}); - return _client.decode(res.data, RejectInviteResponse.fromJson); + _checkInitialized(); + return _client.rejectChannelInvite(id!, type, message: message); } /// Add members to the channel @@ -833,11 +844,8 @@ class Channel { List memberIds, [ Message? message, ]) async { - final res = await _client.post(_channelURL, data: { - 'add_members': memberIds, - 'message': message?.toJson(), - }); - return _client.decode(res.data, AddMembersResponse.fromJson); + _checkInitialized(); + return _client.addChannelMembers(id!, type, memberIds, message: message); } /// Invite members to the channel @@ -845,11 +853,8 @@ class Channel { List memberIds, [ Message? message, ]) async { - final res = await _client.post(_channelURL, data: { - 'invites': memberIds, - 'message': message?.toJson(), - }); - return _client.decode(res.data, InviteMembersResponse.fromJson); + _checkInitialized(); + return _client.inviteChannelMembers(id!, type, memberIds, message: message); } /// Remove members from the channel @@ -857,11 +862,8 @@ class Channel { List memberIds, [ Message? message, ]) async { - final res = await _client.post(_channelURL, data: { - 'remove_members': memberIds, - 'message': message?.toJson(), - }); - return _client.decode(res.data, RemoveMembersResponse.fromJson); + _checkInitialized(); + return _client.removeChannelMembers(id!, type, memberIds, message: message); } /// Send action for a specific message of this channel @@ -870,30 +872,27 @@ class Channel { Map formData, ) async { _checkInitialized(); - final messageId = message.id; - final response = await _client.post('/messages/$messageId/action', data: { - 'id': id, - 'type': type, - 'form_data': formData, - 'message_id': messageId, - }); - - final res = _client.decode(response.data, SendActionResponse.fromJson); + final res = await _client.sendAction(id!, type, messageId, formData); + // update the passed message with response message if (res.message != null) { state!.addMessage(res.message!); } else { + // remove the passed message if response does + // not contain message final oldIndex = state!.messages.indexWhere((m) => m.id == messageId); - Message? oldMessage; + // remove regular message if present if (oldIndex != -1) { - oldMessage = state!.messages[oldIndex]; + final oldMessage = state!.messages[oldIndex]; state!.updateChannelState(state!._channelState.copyWith( messages: state?.messages?..remove(oldMessage), )); } else { - oldMessage = state!.threads.values + // remove thread message if present + // also reduces total reply count + final oldMessage = state!.threads.values .expand((messages) => messages) .firstWhereOrNull((m) => m.id == messageId); if (oldMessage?.parentId != null) { @@ -908,36 +907,28 @@ class Channel { state!.threads[oldMessage.parentId!]!..remove(oldMessage)); } } - await _client.chatPersistenceClient?.deleteMessageById(messageId); } - return res; } - /// Mark all channel messages as read - Future markRead() async { + /// Mark all messages as read + /// Optionally provide a [messageId] if you want to mark a + /// particular message as read + Future markRead({String? messageId}) async { _checkInitialized(); - client.state.totalUnreadCount = max( - 0, (client.state.totalUnreadCount ?? 0) - (state!.unreadCount ?? 0)); - state!._unreadCountController.add(0); - final response = await _client.post('$_channelURL/read', data: {}); - return _client.decode(response.data, EmptyResponse.fromJson); + client.state.totalUnreadCount = + max(0, (client.state.totalUnreadCount) - (state!.unreadCount)); + state!.unreadCount = 0; + return _client.markChannelRead(id!, type, messageId: messageId); } /// Loads the initial channel state and watches for changes - Future watch([Map options = const {}]) async { - final watchOptions = Map.from({ - 'state': true, - 'watch': true, - 'presence': false, - }) - ..addAll(options); - + Future watch() async { ChannelState response; try { - response = await query(options: watchOptions); + response = await query(watch: true); } catch (error, stackTrace) { if (!_initializedCompleter.isCompleted) { _initializedCompleter.completeError(error, stackTrace); @@ -956,7 +947,7 @@ class Channel { state = ChannelClientState(this, channelState); if (cid != null) { - client.state.channels[cid!] = this; + client.state.channels = {cid!: this}; } if (!_initializedCompleter.isCompleted) { _initializedCompleter.complete(true); @@ -965,19 +956,16 @@ class Channel { /// Stop watching the channel Future stopWatching() async { - final response = await _client.post( - '$_channelURL/stop-watching', - data: {}, - ); - return _client.decode(response.data, EmptyResponse.fromJson); + _checkInitialized(); + return _client.stopChannelWatching(id!, type); } /// 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 Future getReplies( - String parentId, - PaginationParams options, { + String parentId, { + PaginationParams? options, bool preferOffline = false, }) async { final cachedReplies = await _client.chatPersistenceClient?.getReplies( @@ -990,50 +978,32 @@ class Channel { return QueryRepliesResponse()..messages = cachedReplies; } } - - final response = await _client.get('/messages/$parentId/replies', - queryParameters: options.toJson()); - - final repliesResponse = _client.decode( - response.data, - QueryRepliesResponse.fromJson, + final repliesResponse = await _client.getReplies( + parentId, + options: options, ); - state?.updateThreadInfo(parentId, repliesResponse.messages); - return repliesResponse; } /// List the reactions for a message in the channel Future getReactions( - String messageID, - PaginationParams options, - ) async { - final response = await _client.get( - '/messages/$messageID/reactions', - queryParameters: options.toJson(), - ); - return _client.decode( - response.data, QueryReactionsResponse.fromJson); - } + String messageId, { + PaginationParams? pagination, + }) => + _client.getReactions( + messageId, + pagination: pagination, + ); /// Retrieves a list of messages by ID Future getMessagesById( - List messageIDs) async { - final response = await _client.get( - '$_channelURL/messages', - queryParameters: {'ids': messageIDs.join(',')}, - ); - - final res = _client.decode( - response.data, - GetMessagesByIdResponse.fromJson, - ); - + List messageIDs, + ) async { + _checkInitialized(); + final res = await _client.getMessagesById(id!, type, messageIDs); final messages = res.messages; - state?.updateChannelState(ChannelState(messages: messages)); - return res; } @@ -1041,85 +1011,59 @@ class Channel { Future translateMessage( String messageId, String language, - ) async { - final response = await _client.post( - '/messages/$messageId/translate', - data: { - 'language': language, - }, - ); - return _client.decode( - response.data, - TranslateMessageResponse.fromJson, - ); - } + ) => + _client.translateMessage( + messageId, + language, + ); /// Creates a new channel - Future create() async => query(options: { - 'watch': false, - 'state': false, - 'presence': false, - }); + 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 Future query({ - Map options = const {}, + bool state = true, + bool watch = false, + bool presence = false, PaginationParams? messagesPagination, PaginationParams? membersPagination, PaginationParams? watchersPagination, bool preferOffline = false, }) async { - var path = '/channels/$type'; - if (id != null) path = '$path/$id'; - path = '$path/query'; - - final payload = Map.from({ - 'state': true, - }) - ..addAll(options); - - if (_extraData.isNotEmpty) { - payload['data'] = _extraData; - } - - if (messagesPagination != null) { - payload['messages'] = messagesPagination.toJson(); - } - if (membersPagination != null) { - payload['members'] = membersPagination.toJson(); - } - if (watchersPagination != null) { - payload['watchers'] = watchersPagination.toJson(); - } - if (preferOffline && cid != null) { - final updatedState = - (await _client.chatPersistenceClient?.getChannelStateByCid( - cid!, - messagePagination: messagesPagination, - ))!; - if (updatedState.messages.isNotEmpty) { - if (state == null) { + final updatedState = await _client.chatPersistenceClient + ?.getChannelStateByCid(cid!, messagePagination: messagesPagination); + if (updatedState != null && updatedState.messages.isNotEmpty) { + if (this.state == null) { _initState(updatedState); } else { - state?.updateChannelState(updatedState); + this.state?.updateChannelState(updatedState); } return updatedState; } } try { - final response = await _client.post(path, data: payload); - final updatedState = _client.decode(response.data, ChannelState.fromJson); + final updatedState = await _client.queryChannel( + type, + channelId: id, + channelData: _extraData, + state: state, + watch: watch, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); if (_id == null) { _id = updatedState.channel!.id; _cid = updatedState.channel!.cid; } - state?.updateChannelState(updatedState); + this.state?.updateChannelState(updatedState); return updatedState; } catch (e) { if (!_client.persistenceEnabled) { @@ -1137,45 +1081,26 @@ class Channel { Filter? filter, List? sort, PaginationParams? pagination, - }) async { - final payload = { - 'sort': sort, - 'filter_conditions': filter ?? {}, - 'type': type, - }; - - if (pagination != null) { - payload.addAll(pagination.toJson()); - } - - if (id != null) { - payload['id'] = id; - } else if (state?.members.isNotEmpty == true) { - payload['members'] = state!.members; - } - - final rawRes = await _client.get('/members', queryParameters: { - 'payload': jsonEncode(payload), - }); - final response = _client.decode(rawRes.data, QueryMembersResponse.fromJson); - return response; - } + }) => + _client.queryMembers( + type, + channelId: id, + filter: filter, + members: state?.members, + sort: sort, + pagination: pagination, + ); /// Mutes the channel - Future mute({Duration? expiration}) async { - final response = await _client.post('/moderation/mute/channel', data: { - 'channel_cid': cid, - if (expiration != null) 'expiration': expiration.inMilliseconds, - }); - return _client.decode(response.data, EmptyResponse.fromJson); + Future mute({Duration? expiration}) { + _checkInitialized(); + return _client.muteChannel(cid!, expiration: expiration); } /// Unmutes the channel - Future unmute() async { - final response = await _client.post('/moderation/unmute/channel', data: { - 'channel_cid': cid, - }); - return _client.decode(response.data, EmptyResponse.fromJson); + Future unmute() { + _checkInitialized(); + return _client.unmuteChannel(cid!); } /// Bans a user from the channel @@ -1229,9 +1154,11 @@ class Channel { /// will be removed for the user Future hide({bool clearHistory = false}) async { _checkInitialized(); - final response = await _client - .post('$_channelURL/hide', data: {'clear_history': clearHistory}); - + final response = await _client.hideChannel( + id!, + type, + clearHistory: clearHistory, + ); if (clearHistory == true) { state!.truncate(); final cid = _cid; @@ -1239,15 +1166,13 @@ class Channel { await _client.chatPersistenceClient?.deleteMessageByCid(cid); } } - - return _client.decode(response.data, EmptyResponse.fromJson); + return response; } /// Removes the hidden status for the channel Future show() async { _checkInitialized(); - final response = await _client.post('$_channelURL/show'); - return _client.decode(response.data, EmptyResponse.fromJson); + return _client.showChannel(id!, type); } /// Stream of [Event] coming from websocket connection specific for the @@ -1329,9 +1254,11 @@ class ChannelClientState { _channel._client.chatPersistenceClient ?.updateChannelState(state)) .debounced(const Duration(seconds: 1)) { - retryQueue = RetryQueue( + _retryQueue = RetryQueue( channel: _channel, - logger: Logger('RETRY QUEUE ${_channel.cid}'), + logger: _channel.client.detachedLogger( + 'âŸŗ (${generateHash([_channel.cid])})', + ), ); _checkExpiredAttachmentMessages(channelState); @@ -1389,7 +1316,7 @@ class ChannelClientState { (r) => r.user.id == _channel._client.state.user?.id, ); if (userRead != null) { - _unreadCountController.add(userRead.unreadMessages); + unreadCount = userRead.unreadMessages; } } @@ -1479,7 +1406,7 @@ class ChannelClientState { BehaviorSubject.seeded(true); /// The retry queue associated to this channel - RetryQueue? retryQueue; + late final RetryQueue _retryQueue; /// Retry failed message Future retryFailedMessages() async { @@ -1498,7 +1425,7 @@ class ChannelClientState { ) .toList(); - retryQueue!.add(failedMessages); + _retryQueue.add(failedMessages); } void _listenReactionDeleted() { @@ -1569,7 +1496,7 @@ class ChannelClientState { } if (_countMessageAsUnread(message)) { - _unreadCountController.add(_unreadCountController.value + 1); + unreadCount += 1; } })); } @@ -1625,11 +1552,11 @@ class ChannelClientState { if (userReadIndex != null && userReadIndex != -1) { final userRead = readList.removeAt(userReadIndex); if (userRead.user.id == _channel._client.state.user!.id) { - _unreadCountController.add(0); + unreadCount = 0; } readList.add(Read( user: event.user!, - lastRead: event.createdAt!, + lastRead: event.createdAt, unreadMessages: event.totalUnreadCount ?? 0, )); _channelState = _channelState.copyWith(read: readList); @@ -1645,7 +1572,7 @@ class ChannelClientState { /// Channel message list as a stream Stream?> get messagesStream => channelStateStream .map((cs) => cs.messages) - .distinct((prev, next) => const ListEquality().equals(prev, next)); + .distinct(const ListEquality().equals); /// Channel pinned message list List? get pinnedMessages => _channelState.pinnedMessages.toList(); @@ -1675,7 +1602,7 @@ class ChannelClientState { _channel.client.state.usersStream, (members, users) => members!.map((e) => e!.copyWith(user: users[e.user!.id])).toList(), - ); + ).distinct(const ListEquality().equals); /// Channel watcher count int? get watcherCount => _channelState.watcherCount; @@ -1705,11 +1632,13 @@ class ChannelClientState { final BehaviorSubject _unreadCountController = BehaviorSubject.seeded(0); + set unreadCount(int value) => _unreadCountController.add(value); + /// Unread count getter as a stream - Stream get unreadCountStream => _unreadCountController.stream; + Stream get unreadCountStream => _unreadCountController.stream.distinct(); /// Unread count getter - int? get unreadCount => _unreadCountController.value; + int get unreadCount => _unreadCountController.value; bool _countMessageAsUnread(Message message) { final userId = _channel.client.state.user?.id; @@ -1834,7 +1763,7 @@ class ChannelClientState { BehaviorSubject.seeded({}); set _threads(Map> v) { - _channel._client.chatPersistenceClient?.updateMessages( + _channel.client.chatPersistenceClient?.updateMessages( _channel.cid!, v.values.expand((v) => v).toList(), ); @@ -1842,15 +1771,17 @@ class ChannelClientState { } /// Channel related typing users last value - List get typingEvents => _typingEventsController.value; + Map get typingEvents => _typingEventsController.value; /// Channel related typing users stream - Stream> get typingEventsStream => _typingEventsController.stream; - final BehaviorSubject> _typingEventsController = - BehaviorSubject.seeded([]); + Stream> get typingEventsStream => + _typingEventsController.stream; + + final BehaviorSubject> _typingEventsController = + BehaviorSubject.seeded({}); final Channel _channel; - final Map _typings = {}; + final Map _typings = {}; void _listenTypingEvents() { if (_channelState.channel?.config.typingEvents == false) { @@ -1864,8 +1795,8 @@ class ChannelClientState { if (event.user != null) { final user = event.user!; if (user.id != _channel.client.state.user?.id) { - _typings[user] = DateTime.now(); - _typingEventsController.add(_typings.keys.toList()); + _typings[user] = event; + _typingEventsController.add(_typings); } } }, @@ -1878,7 +1809,7 @@ class ChannelClientState { final user = event.user!; if (user.id != _channel.client.state.user?.id) { _typings.remove(event.user); - _typingEventsController.add(_typings.keys.toList()); + _typingEventsController.add(_typings); } } }, @@ -1911,7 +1842,7 @@ class ChannelClientState { ); } - late Timer _cleaningTimer; + Timer? _cleaningTimer; void _startCleaning() { if (_channelState.channel?.config.typingEvents == false) { @@ -1956,13 +1887,14 @@ class ChannelClientState { void _clean() { final now = DateTime.now(); - _typings.forEach((user, lastTypingEvent) { - if (now.difference(lastTypingEvent).inSeconds > 7) { + _typings.forEach((user, event) { + if (now.difference(event.createdAt).inSeconds > 7) { _channel.client.handleEvent( Event( type: EventType.typingStop, user: user, cid: _channel.cid, + parentId: event.parentId, ), ); } @@ -1973,12 +1905,12 @@ class ChannelClientState { void dispose() { _debouncedUpdatePersistenceChannelState.cancel(); _unreadCountController.close(); - retryQueue!.dispose(); + _retryQueue.dispose(); _subscriptions.forEach((s) => s.cancel()); _channelStateController.close(); _isUpToDateController.close(); _threadsController.close(); - _cleaningTimer.cancel(); + _cleaningTimer?.cancel(); _pinnedMessagesTimer.cancel(); _typingEventsController.close(); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart new file mode 100644 index 00000000..9276ce22 --- /dev/null +++ b/packages/stream_chat/lib/src/client/client.dart @@ -0,0 +1,1441 @@ +// ignore_for_file: unnecessary_getters_setters + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/api/stream_chat_api.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/utils.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/location.dart'; +import 'package:stream_chat/src/ws/connection_status.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; + +/// Handler function used for logging records. Function requires a single +/// [LogRecord] as the only parameter. +typedef LogHandlerFunction = void Function(LogRecord record); + +final _levelEmojiMapper = { + Level.INFO: 'â„šī¸', + Level.WARNING: 'âš ī¸', + Level.SEVERE: '🚨', +}; + +/// The official Dart client for Stream Chat, +/// a service for building chat applications. +/// This library can be used on any Dart project and on both mobile and web apps +/// with Flutter. +/// +/// You can sign up for a Stream account at https://getstream.io/chat/ +/// +/// The Chat client will manage API call, event handling and manage the +/// websocket connection to Stream Chat servers. +/// +/// ```dart +/// final client = StreamChatClient("stream-chat-api-key"); +/// ``` +class StreamChatClient { + /// Create a client instance with default options. + /// You should only create the client once and re-use it across your + /// application. + StreamChatClient( + String apiKey, { + this.logLevel = Level.WARNING, + LogHandlerFunction? logHandlerFunction, + RetryPolicy? retryPolicy, + Location? location, + @Deprecated('Use location to change baseUrl instead') String? baseURL, + Duration connectTimeout = const Duration(seconds: 6), + Duration receiveTimeout = const Duration(seconds: 6), + StreamChatApi? chatApi, + WebSocket? ws, + AttachmentFileUploader? attachmentFileUploader, + }) { + this.logHandlerFunction = logHandlerFunction ?? _defaultLogHandler; + logger.info('Initiating new StreamChatClient'); + + final options = StreamHttpClientOptions( + baseUrl: baseURL, + location: location, + connectTimeout: connectTimeout, + receiveTimeout: receiveTimeout, + ); + + _chatApi = chatApi ?? + StreamChatApi( + apiKey, + options: options, + tokenManager: _tokenManager, + connectionIdManager: _connectionIdManager, + attachmentFileUploader: attachmentFileUploader, + logger: detachedLogger('đŸ•¸ī¸'), + ); + + _ws = ws ?? + WebSocket( + apiKey: apiKey, + baseUrl: options.baseUrl, + tokenManager: _tokenManager, + handler: handleEvent, + logger: detachedLogger('🔌'), + ); + + _retryPolicy = retryPolicy ?? + RetryPolicy( + shouldRetry: (_, attempt, __) => attempt < 5, + retryTimeout: (_, attempt, __) => Duration(seconds: attempt), + ); + + state = ClientState(this); + } + + late final StreamChatApi _chatApi; + late final WebSocket _ws; + + /// This client state + late ClientState state; + + final _tokenManager = TokenManager(); + final _connectionIdManager = ConnectionIdManager(); + + set chatPersistenceClient(ChatPersistenceClient? value) { + _originalChatPersistenceClient = value; + } + + ChatPersistenceClient? _originalChatPersistenceClient; + + /// Chat persistence client + ChatPersistenceClient? get chatPersistenceClient => _chatPersistenceClient; + + ChatPersistenceClient? _chatPersistenceClient; + + /// Whether the chat persistence is available or not + bool get persistenceEnabled => _chatPersistenceClient != null; + + late final RetryPolicy _retryPolicy; + + /// sync state of the channels present inside state, defaults to false + bool _synced = false; + + /// the last dateTime at the which all the channels were synced + DateTime? _lastSyncedAt; + + /// The retry policy options getter + RetryPolicy get retryPolicy => _retryPolicy; + + /// By default the Chat client will write all messages with level Warn or + /// Error to stdout. + /// + /// During development you might want to enable more logging information, + /// you can change the default log level when constructing the client. + /// + /// ```dart + /// final client = StreamChatClient("stream-chat-api-key", + /// logLevel: Level.INFO); + /// ``` + final Level logLevel; + + /// Client specific logger instance. + /// Refer to the class [Logger] to learn more about the specific + /// implementation. + late final Logger logger = detachedLogger('📡'); + + /// A function that has a parameter of type [LogRecord]. + /// This is called on every new log record. + /// By default the client will use the handler returned by + /// [_getDefaultLogHandler]. + /// Setting it you can handle the log messages directly instead of have them + /// written to stdout, + /// this is very convenient if you use an error tracking tool or if you want + /// to centralize your logs into one facility. + /// + /// ```dart + /// myLogHandlerFunction = (LogRecord record) { + /// // do something with the record (ie. send it to Sentry or Fabric) + /// } + /// + /// final client = StreamChatClient("stream-chat-api-key", + /// logHandlerFunction: myLogHandlerFunction); + ///``` + late LogHandlerFunction logHandlerFunction; + + StreamSubscription? _connectionStatusSubscription; + + final _eventController = BehaviorSubject(); + + /// Stream of [Event] coming from [_ws] connection + /// Listen to this or use the [on] method to filter specific event types + Stream get eventStream => _eventController.stream; + + final _wsConnectionStatusController = + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set _wsConnectionStatus(ConnectionStatus status) => + _wsConnectionStatusController.add(status); + + /// The current status value of the [_ws] connection + ConnectionStatus get wsConnectionStatus => + _wsConnectionStatusController.value; + + /// This notifies the connection status of the [_ws] connection. + /// Listen to this to get notified when the [_ws] tries to reconnect. + Stream get wsConnectionStatusStream => + _wsConnectionStatusController.stream.distinct(); + + LogHandlerFunction get _defaultLogHandler => (LogRecord record) { + print( + '${record.time} ' + '${_levelEmojiMapper[record.level] ?? record.level.name} ' + '${record.loggerName} ${record.message} ', + ); + if (record.error != null) print(record.error); + if (record.stackTrace != null) print(record.stackTrace); + }; + + /// + Logger detachedLogger(String name) => Logger.detached(name) + ..level = logLevel + ..onRecord.listen(logHandlerFunction); + + /// Connects the current user, this triggers a connection to the API. + /// It returns a [Future] that resolves when the connection is setup. + Future connectUser(User user, String token) => + _connectUser(user, token: Token.fromRawValue(token)); + + /// Connects the current user using the [tokenProvider] to fetch the token. + /// It returns a [Future] that resolves when the connection is setup. + Future connectUserWithProvider( + User user, TokenProvider tokenProvider) => + _connectUser(user, provider: tokenProvider); + + /// Connects the current user with an anonymous id, this triggers a connection + /// to the API. It returns a [Future] that resolves when the connection is + /// setup. + Future connectAnonymousUser() async { + final token = Token.anonymous(); + final user = OwnUser(id: token.userId); + return _connectUser(user, token: token); + } + + /// Connects the current user as guest, this triggers a connection to the API. + /// It returns a [Future] that resolves when the connection is setup. + Future connectGuestUser(User user) async { + final userId = user.id; + final anonymousToken = Token.anonymous(userId: userId); + + // setting anonymous token so that getGuestUser works + _tokenManager.setTokenOrProvider(userId, token: anonymousToken); + + final guestUser = await _chatApi.guest.getGuestUser(user); + + // resetting tokenManager after successful request + _tokenManager.reset(); + + final guestUserToken = Token.fromRawValue(guestUser.accessToken); + return _connectUser(guestUser.user, token: guestUserToken); + } + + Future _connectUser( + User user, { + Token? token, + TokenProvider? provider, + }) async { + if (_ws.connectionCompleter?.isCompleted == false) { + throw const StreamChatError( + 'User already getting connected, try calling `disconnectUser` ' + 'before trying to connect again', + ); + } + + logger.info('connecting user : ${user.id}'); + + await _tokenManager.setTokenOrProvider( + user.id, + token: token, + provider: provider, + ); + + final ownUser = OwnUser.fromUser(user); + state.user = ownUser; + + try { + if (_originalChatPersistenceClient != null) { + _chatPersistenceClient = _originalChatPersistenceClient; + await _chatPersistenceClient!.connect(ownUser.id); + } + final event = await openConnection(); + return event; + } catch (e, stk) { + if (e is StreamWebSocketError && e.isRetriable) { + final event = await _chatPersistenceClient?.getConnectionInfo(); + if (event != null) return event; + } + logger.severe('error connecting user : ${ownUser.id}', e, stk); + rethrow; + } + } + + /// Creates a new WebSocket connection with the current user. + Future openConnection() async { + assert( + state.user != null, + 'User is not set on client, ' + 'use `connectUser` or `connectAnonymousUser` instead', + ); + + final user = state.user!; + + logger.info('Opening web-socket connection for ${user.id}'); + + if (wsConnectionStatus == ConnectionStatus.connecting) { + throw StreamChatError('Connection already in progress for ${user.id}'); + } + + if (wsConnectionStatus == ConnectionStatus.connected) { + throw StreamChatError('Connection already available for ${user.id}'); + } + + _wsConnectionStatus = ConnectionStatus.connecting; + + // skipping `ws` seed connection status -> ConnectionStatus.disconnected + // otherwise `client.wsConnectionStatusStream` will emit in order + // 1. ConnectionStatus.disconnected -> client seed status + // 2. ConnectionStatus.connecting -> client connecting status + // 3. ConnectionStatus.disconnected -> ws seed status + _connectionStatusSubscription = + _ws.connectionStatusStream.skip(1).listen(_connectionStatusHandler); + + try { + return await _ws.connect(user); + } catch (e, stk) { + logger.severe('error connecting ws', e, stk); + rethrow; + } + } + + /// Disconnects the [_ws] connection, + /// without removing the user set on client. + /// + /// This will not trigger default auto-retry mechanism for reconnection. + /// You need to call [openConnection] to reconnect to [_ws]. + void closeConnection() { + if (wsConnectionStatus == ConnectionStatus.disconnected) return; + + logger.info('Closing web-socket connection for ${state.user?.id}'); + _wsConnectionStatus = ConnectionStatus.disconnected; + + _connectionStatusSubscription?.cancel(); + _connectionStatusSubscription = null; + + _ws.disconnect(); + } + + void _handleHealthCheckEvent(Event event) { + final user = event.me; + if (user != null) state.user = user; + + final connectionId = event.connectionId; + if (connectionId != null) { + _connectionIdManager.setConnectionId(connectionId); + _chatPersistenceClient?.updateConnectionInfo(event); + } + } + + /// Method called to add a new event to the [_eventController]. + void handleEvent(Event event) { + if (event.type == EventType.healthCheck) { + return _handleHealthCheckEvent(event); + } + if (!event.isLocal && _synced) { + _lastSyncedAt = event.createdAt; + _chatPersistenceClient?.updateLastSyncAt(event.createdAt); + } + state.updateUser(event.user); + return _eventController.add(event); + } + + void _connectionStatusHandler(ConnectionStatus status) async { + final currentState = _wsConnectionStatus = status; + + handleEvent(Event( + type: EventType.connectionChanged, + online: status == ConnectionStatus.connected, + )); + + if (currentState == ConnectionStatus.connected) { + // connection recovered + final cids = state.channels.keys.toList(growable: false); + if (cids.isNotEmpty) { + await queryChannelsOnline( + filter: Filter.in_('cid', cids), + paginationParams: const PaginationParams(limit: 30), + ).then((_) => sync(cids: cids, lastSyncAt: _lastSyncedAt)); + } + handleEvent(Event( + type: EventType.connectionRecovered, + online: true, + )); + } else { + _synced = false; + } + } + + /// Stream of [Event] coming from [_ws] connection + /// Pass an eventType as parameter in order to filter just a type of event + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) { + if (eventType == null) return eventStream; + return eventStream.where((event) => + event.type == eventType || + event.type == eventType2 || + event.type == eventType3 || + event.type == eventType4); + } + + /// Get the events missed while offline to sync the offline storage + /// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled] + Future sync({List? cids, DateTime? lastSyncAt}) async { + cids ??= await _chatPersistenceClient?.getChannelCids(); + if (cids == null || cids.isEmpty) { + _synced = true; + return; + } + + lastSyncAt ??= await _chatPersistenceClient?.getLastSyncAt(); + if (lastSyncAt == null) { + _synced = true; + return; + } + + try { + final res = await _chatApi.general.sync(cids, lastSyncAt); + final events = res.events + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + for (final event in events) { + logger.fine('event.type: ${event.type}'); + final messageText = event.message?.text; + if (messageText != null) { + logger.fine('event.message.text: $messageText'); + } + handleEvent(event); + } + + _synced = true; + final now = DateTime.now(); + _lastSyncedAt = now; + _chatPersistenceClient?.updateLastSyncAt(now); + } catch (e, stk) { + _synced = false; + logger.severe('Error during sync', e, stk); + } + } + + final _queryChannelsStreams = >>{}; + + /// Requests channels with a given query. + Stream> queryChannels({ + Filter? filter, + List>? sort, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + PaginationParams paginationParams = const PaginationParams(), + bool waitForConnect = true, + }) async* { + if (!_connectionIdManager.hasConnectionId) { + // ignore: parameter_assignments + watch = false; + } + + final hash = generateHash([ + filter, + sort, + state, + watch, + presence, + memberLimit, + messageLimit, + paginationParams, + ]); + + if (_queryChannelsStreams.containsKey(hash)) { + yield await _queryChannelsStreams[hash]!; + } else { + final channels = await queryChannelsOffline( + filter: filter, + sort: sort, + paginationParams: paginationParams, + ); + if (channels.isNotEmpty) yield channels; + + try { + final newQueryChannelsFuture = queryChannelsOnline( + filter: filter, + sort: sort, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + waitForConnect: waitForConnect, + ).whenComplete(() { + _queryChannelsStreams.remove(hash); + }); + + _queryChannelsStreams[hash] = newQueryChannelsFuture; + + yield await newQueryChannelsFuture; + } catch (_) { + if (channels.isEmpty) rethrow; + } + } + } + + /// Requests channels with a given query from the API. + Future> queryChannelsOnline({ + Filter? filter, + List>? sort, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + bool waitForConnect = true, + PaginationParams paginationParams = const PaginationParams(), + }) async { + if (waitForConnect) { + if (_ws.connectionCompleter?.isCompleted == false) { + logger.info('awaiting connection completer'); + await _ws.connectionCompleter?.future; + } + if (wsConnectionStatus != ConnectionStatus.connected) { + throw const StreamChatError( + 'You cannot use queryChannels without an active connection. ' + 'Please call `connectUser` to connect the client.', + ); + } + } + + if (!_connectionIdManager.hasConnectionId) { + // ignore: parameter_assignments + watch = false; + } + + logger.info('Query channel start'); + final res = await _chatApi.channel.queryChannels( + filter: filter, + sort: sort, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + ); + + if (res.channels.isEmpty && paginationParams.offset == 0) { + logger.warning(''' + We could not find any channel for this query. + Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial + If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart + '''); + return []; + } + + final channels = res.channels; + + final users = channels + .expand((it) => it.members) + .map((it) => it.user) + .toList(growable: false); + + this.state.updateUsers(users); + + logger.info('Got ${res.channels.length} channels from api'); + + final updateData = _mapChannelStateToChannel(channels); + + await _chatPersistenceClient?.updateChannelQueries( + filter, + channels.map((c) => c.channel!.cid).toList(), + clearQueryCache: paginationParams.offset == 0, + ); + + this.state.channels = updateData.key; + return updateData.value; + } + + /// Requests channels with a given query from the Persistence client. + Future> queryChannelsOffline({ + Filter? filter, + List>? sort, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final offlineChannels = (await _chatPersistenceClient?.getChannelStates( + filter: filter, + sort: sort, + paginationParams: paginationParams, + )) ?? + []; + final updatedData = _mapChannelStateToChannel(offlineChannels); + state.channels = updatedData.key; + return updatedData.value; + } + + MapEntry, List> _mapChannelStateToChannel( + List channelStates, + ) { + final channels = {...state.channels}; + final newChannels = []; + for (final channelState in channelStates) { + final channel = channels[channelState.channel!.cid]; + if (channel != null) { + channel.state?.updateChannelState(channelState); + newChannels.add(channel); + } else { + final newChannel = Channel.fromState(this, channelState); + if (newChannel.cid != null) { + channels[newChannel.cid!] = newChannel; + } + newChannels.add(newChannel); + } + } + return MapEntry(channels, newChannels); + } + + /// Requests users with a given query. + Future queryUsers({ + bool? presence, + Filter? filter, + List? sort, + PaginationParams? pagination, + }) async { + final response = await _chatApi.user.queryUsers( + presence: presence ?? _connectionIdManager.hasConnectionId, + filter: filter, + sort: sort, + pagination: pagination, + ); + state.updateUsers(response.users); + return response; + } + + /// A message search. + Future search( + Filter filter, { + String? query, + List? sort, + PaginationParams? paginationParams, + Filter? messageFilters, + }) => + _chatApi.general.searchMessages( + filter, + query: query, + sort: sort, + pagination: paginationParams, + messageFilters: messageFilters, + ); + + /// Send a [file] to the [channelId] of type [channelType] + Future sendFile( + AttachmentFile file, + String channelId, + String channelType, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.sendFile( + file, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + + /// Send a [image] to the [channelId] of type [channelType] + Future sendImage( + AttachmentFile image, + String channelId, + String channelType, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.sendImage( + image, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + + /// Delete a file from this channel + Future deleteFile( + String url, + String channelId, + String channelType, { + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.deleteFile( + url, + channelId, + channelType, + cancelToken: cancelToken, + ); + + /// Delete an image from this channel + Future deleteImage( + String url, + String channelId, + String channelType, { + CancelToken? cancelToken, + }) => + _chatApi.fileUploader.deleteImage( + url, + channelId, + channelType, + cancelToken: cancelToken, + ); + + /// Replaces the [channelId] of type [ChannelType] data with [data] + Future updateChannel( + String channelId, + String channelType, + Map data, { + Message? message, + }) => + _chatApi.channel.updateChannel( + channelId, + channelType, + data, + message: message, + ); + + /// Updates the [channelId] of type [ChannelType] data with [data] + Future updateChannelPartial( + String channelId, + String channelType, { + Map? set, + List? unset, + }) => + _chatApi.channel.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + + /// Add a device for Push Notifications. + Future addDevice(String id, PushProvider pushProvider) => + _chatApi.device.addDevice(id, pushProvider); + + /// Gets a list of user devices. + Future getDevices() => _chatApi.device.getDevices(); + + /// Remove a user's device. + Future removeDevice(String id) => + _chatApi.device.removeDevice(id); + + /// Get a development token + Token devToken(String userId) => Token.development(userId); + + /// Returns a channel client with the given type, id and custom data. + Channel channel( + String type, { + String? id, + Map? extraData, + }) { + if (id != null && state.channels.containsKey('$type:$id')) { + return state.channels['$type:$id']!; + } + return Channel(this, type, id, extraData: extraData); + } + + /// Creates a new channel + Future createChannel( + String channelType, { + String? channelId, + Map? channelData, + }) => + queryChannel( + channelType, + channelId: channelId, + state: false, + channelData: channelData, + ); + + /// watches the provided channel + /// Creates first if not yet created + Future watchChannel( + String channelType, { + String? channelId, + Map? channelData, + }) => + queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: channelData, + ); + + /// Query the API, get messages, members or other channel fields + /// Creates the channel first if not yet created + Future queryChannel( + String channelType, { + bool state = true, + bool watch = false, + bool presence = false, + String? channelId, + Map? channelData, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) => + _chatApi.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: state, + watch: watch, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + + /// Query channel members + Future queryMembers( + String channelType, { + Filter? filter, + String? channelId, + List? members, + List? sort, + PaginationParams? pagination, + }) => + _chatApi.general.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: members, + sort: sort, + pagination: pagination, + ); + + /// Hides the channel from [queryChannels] for the user + /// until a message is added If [clearHistory] is set to true - all messages + /// will be removed for the user + Future hideChannel( + String channelId, + String channelType, { + bool clearHistory = false, + }) => + _chatApi.channel.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + ); + + /// Removes the hidden status for the channel + Future showChannel( + String channelId, + String channelType, + ) => + _chatApi.channel.showChannel( + channelId, + channelType, + ); + + /// Delete this channel. Messages are permanently removed. + Future deleteChannel( + String channelId, + String channelType, + ) => + _chatApi.channel.deleteChannel( + channelId, + channelType, + ); + + /// Removes all messages from the channel + Future truncateChannel( + String channelId, + String channelType, + ) => + _chatApi.channel.truncateChannel( + channelId, + channelType, + ); + + /// Mutes the channel + Future muteChannel( + String channelCid, { + Duration? expiration, + }) => + _chatApi.moderation.muteChannel( + channelCid, + expiration: expiration, + ); + + /// Unmutes the channel + Future unmuteChannel(String channelCid) => + _chatApi.moderation.unmuteChannel(channelCid); + + /// Accept invitation to the channel + Future acceptChannelInvite( + String channelId, + String channelType, { + Message? message, + }) => + _chatApi.channel.acceptChannelInvite( + channelId, + channelType, + message: message, + ); + + /// Reject invitation to the channel + Future rejectChannelInvite( + String channelId, + String channelType, { + Message? message, + }) => + _chatApi.channel.rejectChannelInvite( + channelId, + channelType, + message: message, + ); + + /// Add members to the channel + Future addChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) => + _chatApi.channel.addMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + /// Remove members from the channel + Future removeChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) => + _chatApi.channel.removeMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + /// Invite members to the channel + Future inviteChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) => + _chatApi.channel.inviteChannelMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + /// Stop watching the channel + Future stopChannelWatching( + String channelId, + String channelType, + ) => + _chatApi.channel.stopWatching( + channelId, + channelType, + ); + + /// Send action for a specific message of this channel + Future sendAction( + String channelId, + String channelType, + String messageId, + Map formData, + ) => + _chatApi.message.sendAction( + channelId, + channelType, + messageId, + formData, + ); + + /// Mark [channelId] of type [channelType] all messages as read + /// Optionally provide a [messageId] if you want to mark a + /// particular message as read + Future markChannelRead( + String channelId, + String channelType, { + String? messageId, + }) => + _chatApi.channel.markRead( + channelId, + channelType, + messageId: messageId, + ); + + /// Update or Create the given user object. + Future updateUser(User user) => updateUsers([user]); + + /// Batch update a list of users + Future updateUsers(List users) => + _chatApi.user.updateUsers(users); + + /// Bans a user from all channels + Future banUser( + String targetUserId, [ + Map options = const {}, + ]) => + _chatApi.moderation.banUser( + targetUserId, + options: options, + ); + + /// Remove global ban for a user + Future unbanUser( + String targetUserId, [ + Map options = const {}, + ]) => + _chatApi.moderation.unbanUser( + targetUserId, + options: options, + ); + + /// Shadow bans a user + Future shadowBan( + String targetID, [ + Map options = const {}, + ]) => + banUser(targetID, { + 'shadow': true, + ...options, + }); + + /// Removes shadow ban from a user + Future removeShadowBan( + String targetID, [ + Map options = const {}, + ]) => + unbanUser(targetID, { + 'shadow': true, + ...options, + }); + + /// Mutes a user + Future muteUser(String userId) => + _chatApi.moderation.muteUser(userId); + + /// Unmutes a user + Future unmuteUser(String userId) => + _chatApi.moderation.unmuteUser(userId); + + /// Flag a message + Future flagMessage(String messageId) => + _chatApi.moderation.flagMessage(messageId); + + /// Unflag a message + Future unflagMessage(String messageId) => + _chatApi.moderation.unflagMessage(messageId); + + /// Flag a user + Future flagUser(String userId) => + _chatApi.moderation.flagUser(userId); + + /// Unflag a message + Future unflagUser(String userId) => + _chatApi.moderation.unflagUser(userId); + + /// Mark all channels for this user as read + Future markAllRead() => _chatApi.channel.markAllRead(); + + /// Send an event to a particular channel + Future sendEvent( + String channelId, + String channelType, + Event event, + ) => + _chatApi.channel.sendEvent( + channelId, + channelType, + event, + ); + + /// Send a [reactionType] for this [messageId] + /// Set [enforceUnique] to true to remove the existing user reaction + Future sendReaction( + String messageId, + String reactionType, { + Map extraData = const {}, + bool enforceUnique = false, + }) => + _chatApi.message.sendReaction( + messageId, + reactionType, + extraData: extraData, + enforceUnique: enforceUnique, + ); + + /// Delete a [reactionType] from this [messageId] + Future deleteReaction( + String messageId, + String reactionType, + ) => + _chatApi.message.deleteReaction( + messageId, + reactionType, + ); + + /// Sends the message to the given channel + Future sendMessage( + Message message, + String channelId, + String channelType, { + bool skipPush = false, + }) => + _chatApi.message.sendMessage( + channelId, + channelType, + message, + skipPush: skipPush, + ); + + /// Lists all the message replies for the [parentId] + Future getReplies( + String parentId, { + PaginationParams? options, + }) => + _chatApi.message.getReplies( + parentId, + options: options, + ); + + /// Get all the reactions for a [messageId] + Future getReactions( + String messageId, { + PaginationParams? pagination, + }) => + _chatApi.message.getReactions( + messageId, + pagination: pagination, + ); + + /// Update the given message + Future updateMessage(Message message) => + _chatApi.message.updateMessage(message); + + /// Partially update the given [messageId] + /// Use [set] to define values to be set + /// Use [unset] to define values to be unset + Future partialUpdateMessage( + String messageId, { + Map? set, + List? unset, + }) => + _chatApi.message.partialUpdateMessage( + messageId, + set: set, + unset: unset, + ); + + /// Deletes the given message + Future deleteMessage(String messageId) => + _chatApi.message.deleteMessage(messageId); + + /// Get a message by [messageId] + Future getMessage(String messageId) => + _chatApi.message.getMessage(messageId); + + /// Retrieves a list of messages by [messageIDs] + /// from the given [channelId] of type [channelType] + Future getMessagesById( + String channelId, + String channelType, + List messageIDs, + ) => + _chatApi.message.getMessagesById( + channelId, + channelType, + messageIDs, + ); + + /// Translates the [messageId] in provided [language] + Future translateMessage( + String messageId, + String language, + ) => + _chatApi.message.translateMessage( + messageId, + language, + ); + + /// Pins provided message + /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds + /// to be added to [DateTime.now] + Future pinMessage( + String messageId, { + Object? /*num|DateTime*/ timeoutOrExpirationDate, + }) { + assert(() { + if (timeoutOrExpirationDate is! DateTime && + timeoutOrExpirationDate != null && + timeoutOrExpirationDate is! num) { + throw ArgumentError('Invalid timeout or Expiration date'); + } + return true; + }(), 'Check for invalid timeout or expiration date'); + + DateTime? pinExpires; + if (timeoutOrExpirationDate is DateTime) { + pinExpires = timeoutOrExpirationDate; + } else if (timeoutOrExpirationDate is num) { + pinExpires = DateTime.now().add( + Duration(seconds: timeoutOrExpirationDate.toInt()), + ); + } + return partialUpdateMessage( + messageId, + set: { + 'pinned': true, + 'pin_expires': pinExpires?.toUtc().toIso8601String(), + }, + ); + } + + /// Unpins provided message + Future unpinMessage(String messageId) => + partialUpdateMessage( + messageId, + set: { + 'pinned': false, + }, + ); + + /// Closes the [_ws] connection and resets the [state] + /// If [flushChatPersistence] is true the client deletes all offline + /// user's data. + Future disconnectUser({bool flushChatPersistence = false}) async { + logger.info('Disconnecting user : ${state.user?.id}'); + + // resetting state + state.dispose(); + state = ClientState(this); + + // resetting credentials + _tokenManager.reset(); + _connectionIdManager.reset(); + + // disconnecting persistence client + await _chatPersistenceClient?.disconnect(flush: flushChatPersistence); + _chatPersistenceClient = null; + + // closing web-socket connection + closeConnection(); + } + + /// Call this function to dispose the client + Future dispose() async { + logger.info('Disposing new StreamChatClient'); + + // disposing state + state.dispose(); + + // disconnecting persistence client + await _chatPersistenceClient?.disconnect(); + + // closing web-socket connection + closeConnection(); + + await _eventController.close(); + await _wsConnectionStatusController.close(); + } +} + +/// The class that handles the state of the channel listening to the events +class ClientState { + /// Creates a new instance listening to events and updating the state + ClientState(this._client) { + _subscriptions.addAll([ + _client + .on() + .where((event) => event.me != null) + .map((e) => e.me) + .listen((user) { + _userController.add(user); + final totalUnreadCount = user?.totalUnreadCount; + if (totalUnreadCount != null) { + _totalUnreadCountController.add(totalUnreadCount); + } + + final unreadChannels = user?.unreadChannels; + if (unreadChannels != null) { + _unreadChannelsController.add(unreadChannels); + } + }), + _client + .on() + .map((event) => event.unreadChannels) + .whereType() + .listen(_unreadChannelsController.add), + _client + .on() + .map((event) => event.totalUnreadCount) + .whereType() + .listen(_totalUnreadCountController.add), + ]); + + _listenChannelDeleted(); + + _listenChannelHidden(); + + _listenUserUpdated(); + } + + final _subscriptions = []; + + /// Used internally for optimistic update of unread count + set totalUnreadCount(int unreadCount) { + _totalUnreadCountController.add(unreadCount); + } + + void _listenChannelHidden() { + _subscriptions.add(_client.on(EventType.channelHidden).listen((event) { + final cid = event.cid; + + if (cid != null) { + _client.chatPersistenceClient?.deleteChannels([cid]); + } + channels = channels..removeWhere((cid, ch) => cid == event.cid); + })); + } + + void _listenUserUpdated() { + _subscriptions.add(_client.on(EventType.userUpdated).listen((event) { + if (event.user!.id == user!.id) { + user = OwnUser.fromJson(event.user!.toJson()); + } + updateUser(event.user); + })); + } + + void _listenChannelDeleted() { + _subscriptions.add(_client + .on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + EventType.notificationChannelDeleted, + ) + .listen((Event event) async { + final eventChannel = event.channel!; + await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); + channels = channels..remove(eventChannel.cid); + })); + } + + final StreamChatClient _client; + + /// Update user information + set user(OwnUser? user) { + _userController.add(user); + } + + /// Update all the [users] with the provided [userList] + void updateUsers(List userList) { + final newUsers = { + ...users, + for (var user in userList) + if (user != null) user.id: user, + }; + _usersController.add(newUsers); + } + + /// Update the passed [user] in state + void updateUser(User? user) => updateUsers([user]); + + /// The current user + OwnUser? get user => _userController.valueOrNull; + + /// The current user as a stream + Stream get userStream => _userController.stream; + + /// The current user + Map get users => _usersController.value; + + /// The current user as a stream + Stream> get usersStream => _usersController.stream; + + /// The current unread channels count + int get unreadChannels => _unreadChannelsController.value; + + /// The current unread channels count as a stream + Stream get unreadChannelsStream => _unreadChannelsController.stream; + + /// The current total unread messages count + int get totalUnreadCount => _totalUnreadCountController.value; + + /// The current total unread messages count as a stream + Stream get totalUnreadCountStream => _totalUnreadCountController.stream; + + /// The current list of channels in memory as a stream + Stream> get channelsStream => _channelsController.stream; + + /// The current list of channels in memory + Map get channels => _channelsController.value; + + set channels(Map channelMap) { + final newChannels = {...channels, ...channelMap}; + _channelsController.add(newChannels); + } + + final _channelsController = BehaviorSubject>.seeded({}); + final _userController = BehaviorSubject(); + final _usersController = BehaviorSubject>.seeded({}); + final _unreadChannelsController = BehaviorSubject.seeded(0); + final _totalUnreadCountController = BehaviorSubject.seeded(0); + + /// Call this method to dispose this object + void dispose() { + _subscriptions.forEach((s) => s.cancel()); + _userController.close(); + _unreadChannelsController.close(); + _totalUnreadCountController.close(); + channels.values.forEach((c) => c.dispose()); + _channelsController.close(); + } +} diff --git a/packages/stream_chat/lib/src/client/retry_policy.dart b/packages/stream_chat/lib/src/client/retry_policy.dart new file mode 100644 index 00000000..5b7812ab --- /dev/null +++ b/packages/stream_chat/lib/src/client/retry_policy.dart @@ -0,0 +1,37 @@ +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/error/error.dart'; + +/// The retry options +/// When sending/updating/deleting a message any temporary error will trigger the retry policy +/// The retry policy exposes 2 methods +/// - shouldRetry: returns a boolean if the request should be retried +/// - retryTimeout: How many milliseconds to wait till the next attempt +/// +/// maxRetryAttempts is a hard limit on maximum retry attempts before giving up +class RetryPolicy { + /// Instantiate a new RetryPolicy + RetryPolicy({ + required this.shouldRetry, + required this.retryTimeout, + this.maxRetryAttempts = 6, + }); + + /// Hard limit on maximum retry attempts before giving up, defaults to 6 + /// Resets once connection recovers. + final int maxRetryAttempts; + + /// This function evaluates if we should retry the failure + final bool Function( + StreamChatClient client, + int attempt, + StreamChatError? error, + ) shouldRetry; + + /// In the case that we want to retry a failed request the retryTimeout + /// method is called to determine the timeout + final Duration Function( + StreamChatClient client, + int attempt, + StreamChatError? error, + ) retryTimeout; +} diff --git a/packages/stream_chat/lib/src/client/retry_queue.dart b/packages/stream_chat/lib/src/client/retry_queue.dart new file mode 100644 index 00000000..4c44433f --- /dev/null +++ b/packages/stream_chat/lib/src/client/retry_queue.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// The retry queue associated to a channel +class RetryQueue { + /// Instantiate a new RetryQueue object + RetryQueue({ + required this.channel, + this.logger, + }) : client = channel.client { + _retryPolicy = client.retryPolicy; + _listenConnectionRecovered(); + _listenFailedEvents(); + } + + /// The channel of this queue + final Channel channel; + + /// The client associated with this [channel] + final StreamChatClient client; + + /// The logger associated to this queue + final Logger? logger; + + late final RetryPolicy _retryPolicy; + + final _compositeSubscription = CompositeSubscription(); + + final _messageQueue = HeapPriorityQueue(_byDate); + bool _isRetrying = false; + + void _listenConnectionRecovered() { + client.on(EventType.connectionRecovered).listen((event) { + if (event.online == true) { + _startRetrying(); + } + }).addTo(_compositeSubscription); + } + + void _listenFailedEvents() { + channel.on().where((event) => event.message != null).listen((event) { + final message = event.message!; + final containsMessage = _messageQueue.containsMessage(message); + if (!containsMessage) return; + if (message.status == MessageSendingStatus.sent) { + logger?.info('Removing sent message from queue : ${message.id}'); + _messageQueue.removeMessage(message); + return; + } else { + if ([ + MessageSendingStatus.failed_update, + MessageSendingStatus.failed, + MessageSendingStatus.failed_delete, + ].contains(message.status)) { + logger?.info('Adding failed message from event : ${event.type}'); + add([message]); + } + } + }).addTo(_compositeSubscription); + } + + /// Add a list of messages + void add(List messages) { + if (messages.isEmpty) return; + if (_messageQueue.containsAllMessage(messages)) return; + + logger?.info('Adding ${messages.length} messages'); + final messageList = _messageQueue.toList(); + // we should not add message if already available in the queue + _messageQueue.addAll(messages.where( + (it) => !messageList.any((m) => m.id == it.id), + )); + _startRetrying(); + } + + Future _startRetrying() async { + if (_isRetrying) return; + _isRetrying = true; + + logger?.info('Started retrying failed messages'); + while (_messageQueue.isNotEmpty) { + logger?.info('${_messageQueue.length} messages remaining in the queue'); + final message = _messageQueue.first; + await _runAndRetry(message); + } + _isRetrying = false; + } + + Future _runAndRetry(Message message) async { + var attempt = 1; + + final maxAttempt = _retryPolicy.maxRetryAttempts; + // early return in case maxAttempt is less than 0 + if (attempt > maxAttempt) return; + + // ignore: literal_only_boolean_expressions + while (true) { + try { + logger?.info('Message (${message.id}) retry attempt $attempt'); + await _retryMessage(message); + logger?.info('Message (${message.id}) sent successfully'); + _messageQueue.removeMessage(message); + break; + } on StreamChatError catch (e) { + // retry logic + final maxAttempt = _retryPolicy.maxRetryAttempts; + if (attempt < maxAttempt) { + final shouldRetry = _retryPolicy.shouldRetry(client, attempt, e); + if (shouldRetry) { + final timeout = _retryPolicy.retryTimeout(client, attempt, e); + // temporary failure, continue + logger?.info( + 'API call failed (attempt $attempt), ' + 'retrying in ${timeout.inSeconds} seconds. Error was $e', + ); + await Future.delayed(timeout); + attempt += 1; + } else { + logger?.info( + 'API call failed (attempt $attempt). ' + 'Giving up for now, will retry when connection recovers. ' + 'Error was $e', + ); + _sendFailedEvent(message); + break; + } + } else { + logger?.info( + 'API call failed (attempt $attempt). ' + 'Exceeds maxRetryAttempt : $maxAttempt ' + 'Giving up for now, will retry when connection recovers. ' + 'Error was $e', + ); + _sendFailedEvent(message); + break; + } + } catch (e) { + logger?.info( + 'API call failed due to unknown error (attempt $attempt). ' + 'Giving up for now, will retry when connection recovers. ' + 'Error was $e', + ); + _sendFailedEvent(message); + break; + } + } + } + + void _sendFailedEvent(Message message) { + final newStatus = message.status == MessageSendingStatus.sending + ? MessageSendingStatus.failed + : message.status == MessageSendingStatus.updating + ? MessageSendingStatus.failed_update + : MessageSendingStatus.failed_delete; + channel.state?.addMessage(message.copyWith(status: newStatus)); + } + + Future _retryMessage(Message message) async { + if (message.status == MessageSendingStatus.failed_update || + message.status == MessageSendingStatus.updating) { + await channel.updateMessage(message); + } else if (message.status == MessageSendingStatus.failed || + message.status == MessageSendingStatus.sending) { + await channel.sendMessage(message); + } else if (message.status == MessageSendingStatus.failed_delete || + message.status == MessageSendingStatus.deleting) { + await channel.deleteMessage(message); + } + } + + /// Whether our [_messageQueue] has messages or not + bool get hasMessages => _messageQueue.isNotEmpty; + + /// Call this method to dispose this object + void dispose() { + _messageQueue.clear(); + _compositeSubscription.dispose(); + } + + static int _byDate(Message m1, Message m2) { + final date1 = _getMessageDate(m1); + final date2 = _getMessageDate(m2); + + if (date1 == null || date2 == null) { + return 0; + } + + return date1.compareTo(date2); + } + + static DateTime? _getMessageDate(Message m1) { + switch (m1.status) { + case MessageSendingStatus.failed_delete: + case MessageSendingStatus.deleting: + return m1.deletedAt; + + case MessageSendingStatus.failed: + case MessageSendingStatus.sending: + return m1.createdAt; + + case MessageSendingStatus.failed_update: + case MessageSendingStatus.updating: + return m1.updatedAt; + default: + return null; + } + } +} + +extension _MessageHeapPriorityQueue on HeapPriorityQueue { + void removeMessage(Message message) { + final list = toUnorderedList(); + final index = list.indexWhere((it) => it.id == message.id); + if (index == -1) return; + final element = list[index]; + remove(element); + } + + bool containsMessage(Message message) { + final list = toUnorderedList(); + final index = list.indexWhere((it) => it.id == message.id); + if (index == -1) return false; + return true; + } + + bool containsAllMessage(List messages) { + if (isEmpty) return false; + final list = toUnorderedList(); + final messageIds = messages.map((it) => it.id); + return list.every((it) => messageIds.contains(it.id)); + } +} diff --git a/packages/stream_chat/lib/src/attachment_file_uploader.dart b/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart similarity index 64% rename from packages/stream_chat/lib/src/attachment_file_uploader.dart rename to packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart index d00dd4e7..d6dc249f 100644 --- a/packages/stream_chat/lib/src/attachment_file_uploader.dart +++ b/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart @@ -1,8 +1,7 @@ import 'package:dio/dio.dart'; -import 'package:stream_chat/src/api/responses.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/extensions/string_extension.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; /// Class responsible for uploading images and files to a given channel abstract class AttachmentFileUploader { @@ -60,7 +59,7 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { /// Creates a new [StreamAttachmentFileUploader] instance. const StreamAttachmentFileUploader(this._client); - final StreamChatClient _client; + final StreamHttpClient _client; @override Future sendImage( @@ -70,33 +69,14 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { ProgressCallback? onSendProgress, CancelToken? cancelToken, }) async { - final filename = file.path?.split('/').last ?? file.name; - final mimeType = filename?.mimeType; - - MultipartFile? multiPartFile; - if (file.path != null) { - multiPartFile = await MultipartFile.fromFile( - file.path!, - filename: filename, - contentType: mimeType, - ); - } else if (file.bytes != null) { - multiPartFile = MultipartFile.fromBytes( - file.bytes!, - filename: filename, - contentType: mimeType, - ); - } - - final response = await _client.post( + final multiPartFile = await file.toMultipartFile(); + final response = await _client.postFile( '/channels/$channelType/$channelId/image', - data: FormData.fromMap({ - 'file': multiPartFile, - }), + multiPartFile, onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return _client.decode(response.data, SendImageResponse.fromJson); + return SendImageResponse.fromJson(response.data); } @override @@ -107,33 +87,14 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { ProgressCallback? onSendProgress, CancelToken? cancelToken, }) async { - final filename = file.path?.split('/').last ?? file.name; - final mimeType = filename?.mimeType; - - MultipartFile? multiPartFile; - if (file.path != null) { - multiPartFile = await MultipartFile.fromFile( - file.path!, - filename: filename, - contentType: mimeType, - ); - } else if (file.bytes != null) { - multiPartFile = MultipartFile.fromBytes( - file.bytes!, - filename: filename, - contentType: mimeType, - ); - } - - final response = await _client.post( + final multiPartFile = await file.toMultipartFile(); + final response = await _client.postFile( '/channels/$channelType/$channelId/file', - data: FormData.fromMap({ - 'file': multiPartFile, - }), + multiPartFile, onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return _client.decode(response.data, SendFileResponse.fromJson); + return SendFileResponse.fromJson(response.data); } @override @@ -148,7 +109,7 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { queryParameters: {'url': url}, cancelToken: cancelToken, ); - return _client.decode(response.data, EmptyResponse.fromJson); + return EmptyResponse.fromJson(response.data); } @override @@ -163,6 +124,6 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { queryParameters: {'url': url}, cancelToken: cancelToken, ); - return _client.decode(response.data, EmptyResponse.fromJson); + return EmptyResponse.fromJson(response.data); } } diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart new file mode 100644 index 00000000..68f6c4f8 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -0,0 +1,295 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/message.dart'; + +/// Defines the api dedicated to channel operations +class ChannelApi { + /// Initialize a new channel api + ChannelApi(this._client); + + final StreamHttpClient _client; + + String _getChannelUrl(String channelId, String channelType) => + '/channels/$channelType/$channelId'; + + /// Query the API, get messages, members or other channel fields + Future queryChannel( + String channelType, { + bool state = true, + bool watch = false, + bool presence = false, + String? channelId, + Map? channelData, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) async { + var channelPath = '/channels/$channelType'; + if (channelId != null) channelPath = '$channelPath/$channelId'; + final response = await _client.post( + '$channelPath/query', + data: { + 'state': state, + 'watch': watch, + 'presence': presence, + if (channelData != null) 'data': channelData, + if (messagesPagination != null) 'messages': messagesPagination, + if (membersPagination != null) 'members': membersPagination, + if (watchersPagination != null) 'watchers': watchersPagination, + }, + ); + return ChannelState.fromJson(response.data); + } + + /// Requests channels with a given query from the API. + Future queryChannels({ + Filter? filter, + List>? sort, + int? memberLimit, + int? messageLimit, + bool state = true, + bool watch = true, + bool presence = false, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final response = await _client.get( + '/channels', + queryParameters: { + 'payload': jsonEncode({ + // default options + 'state': state, + 'watch': watch, + 'presence': presence, + + // passed options + if (sort != null) 'sort': sort, + if (filter != null) 'filter_conditions': filter, + if (memberLimit != null) 'member_limit': memberLimit, + if (messageLimit != null) 'message_limit': messageLimit, + + // pagination + ...paginationParams.toJson() + }), + }, + ); + return QueryChannelsResponse.fromJson(response.data); + } + + /// Mark all channels for this user as read + Future markAllRead() async { + final response = await _client.post('channels/read'); + return EmptyResponse.fromJson(response.data); + } + + /// Replaces the [channelId] of type [ChannelType] data with [data] + Future updateChannel( + String channelId, + String channelType, + Map data, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'data': data, + if (message != null) + 'message': message.copyWith(updatedAt: DateTime.now()), + }, + ); + return UpdateChannelResponse.fromJson(response.data); + } + + /// Updates the [channelId] of type [ChannelType] data with [data] + Future updateChannelPartial( + String channelId, + String channelType, { + Map? set, + List? unset, + }) async { + final response = await _client.patch( + _getChannelUrl(channelId, channelType), + data: { + if (set != null) 'set': set, + if (unset != null) 'unset': unset, + }, + ); + return PartialUpdateChannelResponse.fromJson(response.data); + } + + /// Accept invitation to the channel + Future acceptChannelInvite( + String channelId, + String channelType, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'accept_invite': true, + 'message': message, + }, + ); + return AcceptInviteResponse.fromJson(response.data); + } + + /// Reject invitation to the channel + Future rejectChannelInvite( + String channelId, + String channelType, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'reject_invite': true, + 'message': message, + }, + ); + return RejectInviteResponse.fromJson(response.data); + } + + /// Invite members to the channel + Future inviteChannelMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'invites': memberIds, + 'message': message, + }, + ); + return InviteMembersResponse.fromJson(response.data); + } + + /// Add members to the channel + Future addMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'add_members': memberIds, + 'message': message, + }, + ); + return AddMembersResponse.fromJson(response.data); + } + + /// Remove members from the channel + Future removeMembers( + String channelId, + String channelType, + List memberIds, { + Message? message, + }) async { + final response = await _client.post( + _getChannelUrl(channelId, channelType), + data: { + 'remove_members': memberIds, + 'message': message, + }, + ); + return RemoveMembersResponse.fromJson(response.data); + } + + /// Send an event on this channel + Future sendEvent( + String channelId, + String channelType, + Event event, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/event', + data: {'event': event}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Delete this channel. Messages are permanently removed. + Future deleteChannel( + String channelId, + String channelType, + ) async { + final response = await _client.delete( + _getChannelUrl(channelId, channelType), + ); + return EmptyResponse.fromJson(response.data); + } + + /// Removes all messages from the channel + Future truncateChannel( + String channelId, + String channelType, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/truncate', + ); + return EmptyResponse.fromJson(response.data); + } + + /// 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 + Future hideChannel( + String channelId, + String channelType, { + bool clearHistory = false, + }) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/hide', + data: {'clear_history': clearHistory}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Removes the hidden status for the channel + Future showChannel( + String channelId, + String channelType, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/show', + ); + return EmptyResponse.fromJson(response.data); + } + + /// Mark [channelId] of type [channelType] all messages as read + /// Optionally provide a [messageId] if you want to mark a + /// particular message as read + Future markRead( + String channelId, + String channelType, { + String? messageId, + }) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/read', + data: {if (messageId != null) 'message_id': messageId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Stop watching the channel + Future stopWatching( + String channelId, + String channelType, + ) async { + final response = await _client.post( + '${_getChannelUrl(channelId, channelType)}/stop-watching', + ); + return EmptyResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/device_api.dart b/packages/stream_chat/lib/src/core/api/device_api.dart new file mode 100644 index 00000000..2d2b9d7b --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/device_api.dart @@ -0,0 +1,60 @@ +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; + +/// Provider used to send push notifications. +enum PushProvider { + /// Send notifications using Google's Firebase Cloud Messaging + firebase, + + /// Send notifications using Apple's Push Notification service + apn +} + +/// Helper extension for [PushProvider] +extension PushProviderX on PushProvider { + /// Returns the string notion for [PushProvider]. + String get name => { + PushProvider.apn: 'apn', + PushProvider.firebase: 'firebase', + }[this]!; +} + +/// Defines the api dedicated to device operations +class DeviceApi { + /// Initialize a new device api + DeviceApi(this._client); + + final StreamHttpClient _client; + + /// Add a device for Push Notifications. + Future addDevice( + String deviceId, + PushProvider pushProvider, + ) async { + final response = await _client.post( + '/devices', + data: { + 'id': deviceId, + 'push_provider': pushProvider.name, + }, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Gets a list of user devices. + Future getDevices() async { + final response = await _client.get('/devices'); + return ListDevicesResponse.fromJson(response.data); + } + + /// Remove a user's device. + Future removeDevice( + String deviceId, + ) async { + final response = await _client.delete( + '/devices', + queryParameters: {'id': deviceId}, + ); + return EmptyResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/general_api.dart b/packages/stream_chat/lib/src/core/api/general_api.dart new file mode 100644 index 00000000..c6243148 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/general_api.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; + +/// Defines the api dedicated to general operations +class GeneralApi { + /// Initialize a new general api + GeneralApi(this._client); + + final StreamHttpClient _client; + + /// Get all the missed events + Future sync( + List cids, + DateTime lastSyncAt, + ) async { + final response = await _client.post( + '/sync', + data: { + 'channel_cids': cids, + 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), + }, + ); + return SyncResponse.fromJson(response.data); + } + + /// A message search. + Future searchMessages( + Filter filter, { + String? query, + List? sort, + PaginationParams? pagination, + Filter? messageFilters, + }) async { + assert(() { + if (query == null && messageFilters == null) { + throw ArgumentError('Provide at least `query` or `messageFilters`'); + } + if (query != null && messageFilters != null) { + throw ArgumentError( + "Can't provide both `query` and `messageFilters` at the same time", + ); + } + return true; + }(), 'Check incoming params.'); + + final response = await _client.get( + '/search', + queryParameters: { + 'payload': jsonEncode({ + 'filter_conditions': filter, + if (sort != null) 'sort': sort, + if (query != null) 'query': query, + if (messageFilters != null) + 'message_filter_conditions': messageFilters, + if (pagination != null) ...pagination.toJson(), + }), + }, + ); + + return SearchMessagesResponse.fromJson(response.data); + } + + /// Query channel members + Future queryMembers( + String channelType, { + Filter? filter, + String? channelId, + List? members, + List? sort, + PaginationParams? pagination, + }) async { + final response = await _client.get( + '/members', + queryParameters: { + 'payload': jsonEncode({ + 'type': channelType, + 'filter_conditions': filter ?? {}, + if (channelId != null) + 'id': channelId + else if (members != null) + 'members': members, + if (sort != null) 'sort': sort, + if (pagination != null) ...pagination.toJson(), + }), + }, + ); + + return QueryMembersResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/guest_api.dart b/packages/stream_chat/lib/src/core/api/guest_api.dart new file mode 100644 index 00000000..b6727902 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/guest_api.dart @@ -0,0 +1,20 @@ +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/user.dart'; + +/// Defines the api dedicated to guest users operations +class GuestApi { + /// Initialize a new guest api + GuestApi(this._client); + + final StreamHttpClient _client; + + /// Returns the information about guest user + Future getGuestUser(User user) async { + final response = await _client.post( + '/guest', + data: {'user': user}, + ); + return ConnectGuestUserResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart new file mode 100644 index 00000000..17269820 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -0,0 +1,182 @@ +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/message.dart'; + +/// Defines the api dedicated to messages operations +class MessageApi { + /// Initialize a new message api + MessageApi(this._client); + + final StreamHttpClient _client; + + /// Sends the [message] to the given [channelId] of given [channelType] + Future sendMessage( + String channelId, + String channelType, + Message message, { + bool skipPush = false, + }) async { + final response = await _client.post( + '/channels/$channelType/$channelId/message', + data: { + 'message': message, + 'skip_push': skipPush, + }, + ); + return SendMessageResponse.fromJson(response.data); + } + + /// Retrieves a list of messages by [messageIDs] + /// from the given [channelId] of type [channelType] + Future getMessagesById( + String channelId, + String channelType, + List messageIDs, + ) async { + final response = await _client.get( + '/channels/$channelType/$channelId/messages', + queryParameters: {'ids': messageIDs.join(',')}, + ); + return GetMessagesByIdResponse.fromJson(response.data); + } + + /// Get a message by [messageId] + Future getMessage(String messageId) async { + final response = await _client.get( + '/messages/$messageId', + ); + return GetMessageResponse.fromJson(response.data); + } + + /// Updates the given [message] + Future updateMessage( + Message message, + ) async { + final response = await _client.post( + '/messages/${message.id}', + data: {'message': message}, + ); + return UpdateMessageResponse.fromJson(response.data); + } + + /// Partially update the given [messageId] + /// Use [set] to define values to be set + /// Use [unset] to define values to be unset + Future partialUpdateMessage( + String messageId, { + Map? set, + List? unset, + }) async { + final response = await _client.put( + '/messages/$messageId', + data: { + if (set != null) 'set': set, + if (unset != null) 'unset': unset, + }, + ); + return UpdateMessageResponse.fromJson(response.data); + } + + /// Deletes the given [messageId] + Future deleteMessage( + String messageId, + ) async { + final response = await _client.delete( + '/messages/$messageId', + ); + return EmptyResponse.fromJson(response.data); + } + + /// Send action for a specific [messageId] + /// of the given [channelId] of given [channelType] + Future sendAction( + String channelId, + String channelType, + String messageId, + Map formData, + ) async { + final response = await _client.post( + '/messages/$messageId/action', + data: { + 'id': channelId, + 'type': channelType, + 'form_data': formData, + 'message_id': messageId, + }, + ); + return SendActionResponse.fromJson(response.data); + } + + /// Send a [reactionType] for this [messageId] + /// Set [enforceUnique] to true to remove the existing user reaction + Future sendReaction( + String messageId, + String reactionType, { + Map extraData = const {}, + bool enforceUnique = false, + }) async { + final reaction = Map.from(extraData) + ..addAll({'type': reactionType}); + + final response = await _client.post( + '/messages/$messageId/reaction', + data: { + 'reaction': reaction, + 'enforce_unique': enforceUnique, + }, + ); + return SendReactionResponse.fromJson(response.data); + } + + /// Delete a [reactionType] from this [messageId] + Future deleteReaction( + String messageId, + String reactionType, + ) async { + final response = await _client.delete( + '/messages/$messageId/reaction/$reactionType', + ); + return EmptyResponse.fromJson(response.data); + } + + /// Get all the reactions for a [messageId] + Future getReactions( + String messageId, { + PaginationParams? pagination, + }) async { + final response = await _client.get( + '/messages/$messageId/reactions', + queryParameters: { + if (pagination != null) ...pagination.toJson(), + }, + ); + return QueryReactionsResponse.fromJson(response.data); + } + + /// Translates the [messageId] in provided [language] + Future translateMessage( + String messageId, + String language, + ) async { + final response = await _client.post( + '/messages/$messageId/translate', + data: {'language': language}, + ); + return TranslateMessageResponse.fromJson(response.data); + } + + /// Lists all the message replies for the [parentId] + Future getReplies( + String parentId, { + PaginationParams? options, + }) async { + final response = await _client.get( + '/messages/$parentId/replies', + queryParameters: { + if (options != null) ...options.toJson(), + }, + ); + return QueryRepliesResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/moderation_api.dart b/packages/stream_chat/lib/src/core/api/moderation_api.dart new file mode 100644 index 00000000..533fbf63 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/moderation_api.dart @@ -0,0 +1,128 @@ +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; + +/// Defines the api dedicated to moderation operations +class ModerationApi { + /// Initialize a new moderation api + ModerationApi(this._client); + + final StreamHttpClient _client; + + /// Mutes a user + Future muteUser(String userId) async { + final response = await _client.post( + '/moderation/mute', + data: {'target_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unmutes a user + Future unmuteUser(String userId) async { + final response = await _client.post( + '/moderation/unmute', + data: {'target_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Mutes the channel + Future muteChannel( + String channelCid, { + Duration? expiration, + }) async { + final response = await _client.post( + '/moderation/mute/channel', + data: { + 'channel_cid': channelCid, + if (expiration != null) 'expiration': expiration.inMilliseconds, + }, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unmutes the channel + Future unmuteChannel( + String channelCid, + ) async { + final response = await _client.post( + '/moderation/unmute/channel', + data: {'channel_cid': channelCid}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Flag a message + Future flagMessage( + String messageId, + ) async { + final response = await _client.post( + '/moderation/flag', + data: {'target_message_id': messageId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unflag a message + Future unflagMessage( + String messageId, + ) async { + final response = await _client.post( + '/moderation/unflag', + data: {'target_message_id': messageId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Flag a user + Future flagUser( + String userId, + ) async { + final response = await _client.post( + '/moderation/flag', + data: {'target_user_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Unflag a user + Future unflagUser( + String userId, + ) async { + final response = await _client.post( + '/moderation/unflag', + data: {'target_user_id': userId}, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Bans a user from all channels + Future banUser( + String targetUserId, { + Map? options, + }) async { + final response = await _client.post( + '/moderation/ban', + data: { + 'target_user_id': targetUserId, + if (options != null) ...options, + }, + ); + return EmptyResponse.fromJson(response.data); + } + + /// Remove global ban for a user + Future unbanUser( + String targetUserId, { + Map? options, + }) async { + final response = await _client.delete( + '/moderation/ban', + queryParameters: { + 'target_user_id': targetUserId, + if (options != null) ...options, + }, + ); + return EmptyResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart similarity index 77% rename from packages/stream_chat/lib/src/api/requests.dart rename to packages/stream_chat/lib/src/core/api/requests.dart index e78a80ec..29112d72 100644 --- a/packages/stream_chat/lib/src/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -1,9 +1,10 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; part 'requests.g.dart'; /// Sorting options -@JsonSerializable(createFactory: false) +@JsonSerializable(includeIfNull: false) class SortOption { /// Creates a new SortOption instance /// @@ -18,6 +19,10 @@ class SortOption { this.comparator, }); + /// Create a new instance from a json + factory SortOption.fromJson(Map json) => + _$SortOptionFromJson(json); + /// Ascending order // ignore: constant_identifier_names static const ASC = 1; @@ -41,8 +46,8 @@ class SortOption { } /// Pagination options. -@JsonSerializable(createFactory: false, includeIfNull: false) -class PaginationParams { +@JsonSerializable(includeIfNull: false) +class PaginationParams extends Equatable { /// Creates a new PaginationParams instance /// /// For example: @@ -62,6 +67,10 @@ class PaginationParams { this.lessThanOrEqual, }); + /// Create a new instance from a json + factory PaginationParams.fromJson(Map json) => + _$PaginationParamsFromJson(json); + /// The amount of items requested from the APIs. final int limit; @@ -106,24 +115,12 @@ class PaginationParams { ); @override - @JsonKey(ignore: true) - int get hashCode => - runtimeType.hashCode ^ - limit.hashCode ^ - offset.hashCode ^ - greaterThan.hashCode ^ - greaterThanOrEqual.hashCode ^ - lessThan.hashCode ^ - lessThanOrEqual.hashCode; - - @override - bool operator ==(covariant PaginationParams other) => - identical(this, other) || - runtimeType == other.runtimeType && - limit == other.limit && - offset == other.offset && - greaterThan == other.greaterThan && - greaterThanOrEqual == other.greaterThanOrEqual && - lessThan == other.lessThan && - lessThanOrEqual == other.lessThanOrEqual; + List get props => [ + limit, + offset, + greaterThan, + greaterThanOrEqual, + lessThan, + lessThanOrEqual, + ]; } diff --git a/packages/stream_chat/lib/src/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart similarity index 63% rename from packages/stream_chat/lib/src/api/requests.g.dart rename to packages/stream_chat/lib/src/core/api/requests.g.dart index 1ec7b5fc..6cd935b8 100644 --- a/packages/stream_chat/lib/src/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -6,12 +6,30 @@ part of 'requests.dart'; // JsonSerializableGenerator // ************************************************************************** +SortOption _$SortOptionFromJson(Map json) { + return SortOption( + json['field'] as String, + direction: json['direction'] as int, + ); +} + Map _$SortOptionToJson(SortOption instance) => { 'field': instance.field, 'direction': instance.direction, }; +PaginationParams _$PaginationParamsFromJson(Map json) { + return PaginationParams( + limit: json['limit'] as int, + offset: json['offset'] as int, + greaterThan: json['id_gt'] as String?, + greaterThanOrEqual: json['id_gte'] as String?, + lessThan: json['id_lt'] as String?, + lessThanOrEqual: json['id_lte'] as String?, + ); +} + Map _$PaginationParamsToJson(PaginationParams instance) { final val = { 'limit': instance.limit, diff --git a/packages/stream_chat/lib/src/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart similarity index 89% rename from packages/stream_chat/lib/src/api/responses.dart rename to packages/stream_chat/lib/src/core/api/responses.dart index 92493e8a..e06a213a 100644 --- a/packages/stream_chat/lib/src/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -1,14 +1,15 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/device.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/device.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'responses.g.dart'; @@ -16,7 +17,37 @@ class _BaseResponse { String? duration; } -/// Model response for [StreamChatClient.resync] api call +/// Model response for [StreamChatNetworkError] data +@JsonSerializable() +class ErrorResponse extends _BaseResponse { + /// The http error code + int? code; + + /// The message associated to the error code + String? message; + + /// The backend error code + @JsonKey(name: 'StatusCode') + int? statusCode; + + /// A detailed message about the error + String? moreInfo; + + /// Create a new instance from a json + static ErrorResponse fromJson(Map json) => + _$ErrorResponseFromJson(json); + + /// Serialize to json + Map toJson() => _$ErrorResponseToJson(this); + + @override + String toString() => 'ErrorResponse(code: $code, ' + 'message: $message, ' + 'statusCode: $statusCode, ' + 'moreInfo: $moreInfo)'; +} + +/// Model response for [StreamChatClient.sync] api call @JsonSerializable(createToJson: false) class SyncResponse extends _BaseResponse { /// The list of events diff --git a/packages/stream_chat/lib/src/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart similarity index 94% rename from packages/stream_chat/lib/src/api/responses.g.dart rename to packages/stream_chat/lib/src/core/api/responses.g.dart index 2358593d..deba55f0 100644 --- a/packages/stream_chat/lib/src/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -6,6 +6,24 @@ part of 'responses.dart'; // JsonSerializableGenerator // ************************************************************************** +ErrorResponse _$ErrorResponseFromJson(Map json) { + return ErrorResponse() + ..duration = json['duration'] as String? + ..code = json['code'] as int? + ..message = json['message'] as String? + ..statusCode = json['StatusCode'] as int? + ..moreInfo = json['more_info'] as String?; +} + +Map _$ErrorResponseToJson(ErrorResponse instance) => + { + 'duration': instance.duration, + 'code': instance.code, + 'message': instance.message, + 'StatusCode': instance.statusCode, + 'more_info': instance.moreInfo, + }; + SyncResponse _$SyncResponseFromJson(Map json) { return SyncResponse() ..duration = json['duration'] as String? diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart new file mode 100644 index 00000000..bcf041c1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -0,0 +1,79 @@ +import 'package:logging/logging.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; + +export 'device_api.dart' show PushProvider; + +/// ApiClient that wraps every other specific api +class StreamChatApi { + /// Initialize a new stream chat api + StreamChatApi( + String apiKey, { + StreamHttpClient? client, + StreamHttpClientOptions? options, + TokenManager? tokenManager, + ConnectionIdManager? connectionIdManager, + AttachmentFileUploader? attachmentFileUploader, + Logger? logger, + }) : _fileUploader = attachmentFileUploader, + _client = client ?? + StreamHttpClient( + apiKey, + options: options, + tokenManager: tokenManager, + connectionIdManager: connectionIdManager, + logger: logger, + ); + + final StreamHttpClient _client; + + UserApi? _user; + + /// Api dedicated to users operations + UserApi get user => _user ??= UserApi(_client); + + GuestApi? _guest; + + /// Api dedicated to guest operations + GuestApi get guest => _guest ??= GuestApi(_client); + + MessageApi? _message; + + /// Api dedicated to message operations + MessageApi get message => _message ??= MessageApi(_client); + + ChannelApi? _channel; + + /// Api dedicated to channel operations + ChannelApi get channel => _channel ??= ChannelApi(_client); + + DeviceApi? _device; + + /// Api dedicated to device operations + DeviceApi get device => _device ??= DeviceApi(_client); + + ModerationApi? _moderation; + + /// Api dedicated to moderation operations + ModerationApi get moderation => _moderation ??= ModerationApi(_client); + + GeneralApi? _general; + + /// Api dedicated to general operations + GeneralApi get general => _general ??= GeneralApi(_client); + + AttachmentFileUploader? _fileUploader; + + /// Class responsible for uploading images and files to a given channel + AttachmentFileUploader get fileUploader => + _fileUploader ??= StreamAttachmentFileUploader(_client); +} diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart new file mode 100644 index 00000000..61159731 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/user.dart'; + +/// Defines the api dedicated to users operations +class UserApi { + /// Initialize a new user api + UserApi(this._client); + + final StreamHttpClient _client; + + /// Requests users with a given query. + Future queryUsers({ + bool presence = false, + Filter? filter, + List? sort, + PaginationParams? pagination, + }) async { + final response = await _client.get( + '/users', + queryParameters: { + 'payload': jsonEncode({ + 'presence': presence, + if (sort != null) 'sort': sort, + if (filter != null) 'filter_conditions': filter, + if (pagination != null) ...pagination.toJson(), + }), + }, + ); + return QueryUsersResponse.fromJson(response.data); + } + + /// Batch update a list of users + Future updateUsers( + List users, + ) async { + final response = await _client.post( + '/users', + data: { + 'users': {for (final user in users) user.id: user}, + }, + ); + return UpdateUsersResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/error/chat_error_code.dart b/packages/stream_chat/lib/src/core/error/chat_error_code.dart new file mode 100644 index 00000000..6391428d --- /dev/null +++ b/packages/stream_chat/lib/src/core/error/chat_error_code.dart @@ -0,0 +1,166 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:collection/collection.dart'; + +/// Complete list of errors that are returned by the API +/// together with the description and API code. +enum ChatErrorCode { + // Client errors + + /// Unauthenticated, token not defined + undefinedToken, + + // Bad Request + + /// Wrong data/parameter is sent to the API + inputError, + + /// Duplicate username is sent while enforce_unique_usernames is enabled + duplicateUsername, + + /// Message is too long + messageTooLong, + + /// Event is not supported + eventNotSupported, + + /// The feature is currently disabled + /// on the dashboard (i.e. Reactions & Replies) + channelFeatureNotSupported, + + /// Multiple Levels Reply is not supported + /// the API only supports 1 level deep reply threads + multipleNestling, + + /// Custom Command handler returned an error + customCommandEndpointCall, + + /// App config does not have custom_action_handler_url + customCommandEndpointMissing, + + // Unauthorised + + /// Unauthenticated, problem with authentication + authenticationError, + + /// Unauthenticated, token expired + tokenExpired, + + /// Unauthenticated, token date incorrect + tokenBeforeIssuedAt, + + /// Unauthenticated, token not valid yet + tokenNotValid, + + /// Unauthenticated, token signature invalid + tokenSignatureInvalid, + + /// Access Key invalid + accessKeyError, + + // Forbidden + + /// Unauthorised / forbidden to make request + notAllowed, + + /// App suspended + appSuspended, + + /// User tried to post a message during the cooldown period + cooldownError, + + // Miscellaneous + + /// Resource not found + doesNotExist, + + /// Request timed out + requestTimeout, + + /// Payload too big + payloadTooBig, + + /// Too many requests in a certain time frame + rateLimitError, + + /// Request headers are too large + maximumHeaderSizeExceeded, + + /// Something goes wrong in the system + internalSystemError, + + /// No access to requested channels + noAccessToChannels +} + +const _errorCodeWithDescription = { + ChatErrorCode.undefinedToken: + MapEntry(1000, 'Unauthorised, token not defined'), + ChatErrorCode.inputError: + MapEntry(4, 'Wrong data/parameter is sent to the API'), + ChatErrorCode.duplicateUsername: MapEntry(6, + 'Duplicate username is sent while enforce_unique_usernames is enabled'), + ChatErrorCode.messageTooLong: MapEntry(20, 'Message is too long'), + ChatErrorCode.eventNotSupported: MapEntry(18, 'Event is not supported'), + ChatErrorCode.channelFeatureNotSupported: MapEntry(19, + 'The feature is currently disabled on the dashboard (i.e. Reactions & Replies)'), + ChatErrorCode.multipleNestling: MapEntry(21, + 'Multiple Levels Reply is not supported - the API only supports 1 level deep reply threads'), + ChatErrorCode.customCommandEndpointCall: + MapEntry(45, 'Custom Command handler returned an error'), + ChatErrorCode.customCommandEndpointMissing: + MapEntry(44, 'App config does not have custom_action_handler_url'), + ChatErrorCode.authenticationError: + MapEntry(5, 'Unauthenticated, problem with authentication'), + ChatErrorCode.tokenExpired: MapEntry(40, 'Unauthenticated, token expired'), + ChatErrorCode.tokenBeforeIssuedAt: + MapEntry(42, 'Unauthenticated, token date incorrect'), + ChatErrorCode.tokenNotValid: + MapEntry(41, 'Unauthenticated, token not valid yet'), + ChatErrorCode.tokenSignatureInvalid: + MapEntry(43, 'Unauthenticated, token signature invalid'), + ChatErrorCode.accessKeyError: MapEntry(2, 'Access Key invalid'), + ChatErrorCode.notAllowed: + MapEntry(17, 'Unauthorised / forbidden to make request'), + ChatErrorCode.appSuspended: MapEntry(99, 'App suspended'), + ChatErrorCode.cooldownError: + MapEntry(60, 'User tried to post a message during the cooldown period'), + ChatErrorCode.doesNotExist: MapEntry(16, 'Resource not found'), + ChatErrorCode.requestTimeout: MapEntry(23, 'Request timed out'), + ChatErrorCode.payloadTooBig: MapEntry(22, 'Payload too big'), + ChatErrorCode.rateLimitError: + MapEntry(9, 'Too many requests in a certain time frame'), + ChatErrorCode.maximumHeaderSizeExceeded: + MapEntry(24, 'Request headers are too large'), + ChatErrorCode.internalSystemError: + MapEntry(-1, 'Something goes wrong in the system'), + ChatErrorCode.noAccessToChannels: + MapEntry(70, 'No access to requested channels'), +}; + +const _authenticationErrors = [ + ChatErrorCode.undefinedToken, + ChatErrorCode.authenticationError, + ChatErrorCode.tokenExpired, + ChatErrorCode.tokenBeforeIssuedAt, + ChatErrorCode.tokenNotValid, + ChatErrorCode.tokenSignatureInvalid, + ChatErrorCode.accessKeyError, + ChatErrorCode.noAccessToChannels, +]; + +/// +ChatErrorCode? chatErrorCodeFromCode(int code) => _errorCodeWithDescription.keys + .firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code); + +/// +extension ChatErrorCodeX on ChatErrorCode { + /// + String get message => _errorCodeWithDescription[this]!.value; + + /// + int get code => _errorCodeWithDescription[this]!.key; + + /// + bool get isAuthenticationError => _authenticationErrors.contains(this); +} diff --git a/packages/stream_chat/lib/src/core/error/error.dart b/packages/stream_chat/lib/src/core/error/error.dart new file mode 100644 index 00000000..ac4a1e0d --- /dev/null +++ b/packages/stream_chat/lib/src/core/error/error.dart @@ -0,0 +1,2 @@ +export 'chat_error_code.dart'; +export 'stream_chat_error.dart'; diff --git a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart new file mode 100644 index 00000000..13ccef2a --- /dev/null +++ b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart @@ -0,0 +1,141 @@ +import 'package:equatable/equatable.dart'; +import 'package:stream_chat/src/core/error/chat_error_code.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// +class StreamChatError with EquatableMixin implements Exception { + /// + const StreamChatError(this.message); + + /// Error message + final String message; + + @override + List get props => [message]; + + @override + String toString() => 'StreamChatError(message: $message)'; +} + +/// +class StreamWebSocketError extends StreamChatError { + /// + const StreamWebSocketError( + String message, { + this.data, + }) : super(message); + + /// + factory StreamWebSocketError.fromStreamError(Map error) { + final data = ErrorResponse.fromJson(error); + final message = data.message ?? ''; + return StreamWebSocketError(message, data: data); + } + + /// + factory StreamWebSocketError.fromWebSocketChannelError( + WebSocketChannelException error) { + final message = error.message ?? ''; + return StreamWebSocketError(message); + } + + /// + int? get code => data?.code; + + /// + ChatErrorCode? get errorCode { + final code = this.code; + if (code == null) return null; + return chatErrorCodeFromCode(code); + } + + /// Response body. please refer to [ErrorResponse]. + final ErrorResponse? data; + + /// + bool get isRetriable => data == null; + + @override + List get props => [...super.props, code]; + + @override + String toString() { + var params = 'message: $message'; + if (data != null) params += ', data: $data'; + return 'WebSocketError($params)'; + } +} + +/// +class StreamChatNetworkError extends StreamChatError { + /// + StreamChatNetworkError( + ChatErrorCode errorCode, { + int? statusCode, + this.data, + }) : code = errorCode.code, + statusCode = statusCode ?? data?.statusCode, + super(errorCode.message); + + /// + StreamChatNetworkError.raw({ + required this.code, + required String message, + this.statusCode, + this.data, + }) : super(message); + + /// + factory StreamChatNetworkError.fromDioError(DioError error) { + final response = error.response; + ErrorResponse? errorResponse; + final data = response?.data; + if (data != null) { + errorResponse = ErrorResponse.fromJson(data); + } + return StreamChatNetworkError.raw( + code: errorResponse?.code ?? -1, + message: + errorResponse?.message ?? response?.statusMessage ?? error.message, + statusCode: errorResponse?.statusCode ?? response?.statusCode, + data: errorResponse, + )..stackTrace = error.stackTrace; + } + + /// Error code + final int code; + + /// HTTP status code + final int? statusCode; + + /// Response body. please refer to [ErrorResponse]. + final ErrorResponse? data; + + StackTrace? _stackTrace; + + /// + set stackTrace(StackTrace? stack) => _stackTrace = stack; + + /// + ChatErrorCode? get errorCode => chatErrorCodeFromCode(code); + + /// + bool get isRetriable => data == null; + + @override + List get props => [...super.props, code, statusCode]; + + @override + String toString({bool printStackTrace = false}) { + var params = 'code: $code, message: $message'; + if (statusCode != null) params += ', statusCode: $statusCode'; + if (data != null) params += ', data: $data'; + var msg = 'StreamChatNetworkError($params)'; + + if (printStackTrace && _stackTrace != null) { + msg += '\n$_stackTrace'; + } + return msg; + } +} diff --git a/packages/stream_chat/lib/src/core/http/connection_id_manager.dart b/packages/stream_chat/lib/src/core/http/connection_id_manager.dart new file mode 100644 index 00000000..59dcb1b6 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/connection_id_manager.dart @@ -0,0 +1,27 @@ +// ignore_for_file: use_setters_to_change_properties + +/// Handles the connection id of the websocket connection +class ConnectionIdManager { + /// Initialize a new connection id manager + ConnectionIdManager({ + String? connectionId, + }) : _connectionId = connectionId; + + String? _connectionId; + + /// Get the current connection id + String? get connectionId => _connectionId; + + /// True if there is a connection id + bool get hasConnectionId => _connectionId != null; + + /// Set the connection id + void setConnectionId(String connectionId) { + _connectionId = connectionId; + } + + /// Clear the connection id + void reset() { + _connectionId = null; + } +} diff --git a/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart new file mode 100644 index 00000000..9947eefa --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/interceptor/auth_interceptor.dart @@ -0,0 +1,91 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; + +/// Authentication interceptor that refreshes the token if +/// an auth error is received +class AuthInterceptor extends Interceptor { + /// Initialize a new auth interceptor + AuthInterceptor(this._client, this._tokenManager); + + final StreamHttpClient _client; + + /// The token manager used in the client + final TokenManager _tokenManager; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + late Token token; + try { + token = await _tokenManager.loadToken(); + } catch (_) { + final error = StreamChatNetworkError(ChatErrorCode.undefinedToken); + final dioError = StreamChatDioError( + error: error, + requestOptions: options, + ); + return handler.reject(dioError, true); + } + final params = {'user_id': token.userId}; + final headers = { + 'Authorization': token.rawValue, + 'stream-auth-type': token.authType.raw, + }; + options..queryParameters.addAll(params)..headers.addAll(headers); + return handler.next(options); + } + + @override + void onError( + DioError err, + ErrorInterceptorHandler handler, + ) async { + ErrorResponse? error; + final data = err.response?.data; + if (data != null) error = ErrorResponse.fromJson(data); + if (error?.code == ChatErrorCode.tokenExpired.code) { + if (_tokenManager.isStatic) return handler.next(err); + _client.lock(); + await _tokenManager.loadToken(refresh: true); + _client.unlock(); + try { + final options = err.requestOptions; + final response = await _client.request( + options.path, + cancelToken: options.cancelToken, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + options: Options( + method: options.method, + sendTimeout: options.sendTimeout, + receiveTimeout: options.receiveTimeout, + extra: options.extra, + headers: options.headers, + responseType: options.responseType, + contentType: options.contentType, + validateStatus: options.validateStatus, + receiveDataWhenStatusError: options.receiveDataWhenStatusError, + followRedirects: options.followRedirects, + maxRedirects: options.maxRedirects, + requestEncoder: options.requestEncoder, + responseDecoder: options.responseDecoder, + listFormat: options.listFormat, + ), + ); + return handler.resolve(response); + } on DioError catch (error) { + return handler.next(error); + } + } + return handler.next(err); + } +} diff --git a/packages/stream_chat/lib/src/core/http/interceptor/connection_id_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/connection_id_interceptor.dart new file mode 100644 index 00000000..59e1c4bc --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/interceptor/connection_id_interceptor.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; + +/// Interceptor that injects the connection id in the request params +class ConnectionIdInterceptor extends Interceptor { + /// + ConnectionIdInterceptor(this.connectionIdManager); + + /// + final ConnectionIdManager connectionIdManager; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + if (connectionIdManager.hasConnectionId) { + options.queryParameters.addAll({ + 'connection_id': connectionIdManager.connectionId, + }); + } + handler.next(options); + } +} diff --git a/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart new file mode 100644 index 00000000..f78b46ea --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart @@ -0,0 +1,332 @@ +// ignore_for_file: lines_longer_than_80_chars +// coverage:ignore-file + +import 'dart:math' as math; + +import 'package:dio/dio.dart'; + +/// Step where we're logging +enum InterceptStep { + /// Request + request, + + /// Response + response, + + /// Error + error, +} + +/// Function used to print the log +typedef LogPrint = void Function(InterceptStep step, Object object); + +void _defaultLogPrint(InterceptStep step, Object object) => print(object); + +/// Interceptor dedicated to logging +class LoggingInterceptor extends Interceptor { + /// Initialize a new logging interceptor + LoggingInterceptor({ + this.request = true, + this.requestHeader = false, + this.requestBody = true, + this.responseHeader = false, + this.responseBody = true, + this.error = true, + this.maxWidth = 120, + this.compact = true, + this.logPrint = _defaultLogPrint, + }); + + /// Print request [Options] + final bool request; + + /// Print request header [Options.headers] + final bool requestHeader; + + /// Print request data [Options.data] + final bool requestBody; + + /// Print [Response.data] + final bool responseBody; + + /// Print [Response.headers] + final bool responseHeader; + + /// Print error message + final bool error; + + /// InitialTab count to logPrint json response + static const int initialTab = 1; + + /// 1 tab length + static const String tabStep = ' '; + + /// Print compact json response + final bool compact; + + /// Width size per logPrint + final int maxWidth; + + /// Log printer; defaults logPrint log to console. + /// In flutter, you'd better use debugPrint. + /// you can also write log in a file. + void Function(InterceptStep step, Object object) logPrint; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (request) { + _printRequestHeader(_logPrintRequest, options); + } + if (requestHeader) { + _printMapAsTable( + _logPrintRequest, + options.queryParameters, + header: 'Query Parameters', + ); + final requestHeaders = {...options.headers}; + requestHeaders['contentType'] = options.contentType?.toString(); + requestHeaders['responseType'] = options.responseType.toString(); + requestHeaders['followRedirects'] = options.followRedirects; + requestHeaders['connectTimeout'] = options.connectTimeout; + requestHeaders['receiveTimeout'] = options.receiveTimeout; + _printMapAsTable(_logPrintRequest, requestHeaders, header: 'Headers'); + _printMapAsTable(_logPrintRequest, options.extra, header: 'Extras'); + } + if (requestBody && options.method != 'GET') { + final dynamic data = options.data; + if (data != null) { + if (data is Map) { + _printMapAsTable( + _logPrintRequest, + options.data as Map?, + header: 'Body', + ); + } else if (data is FormData) { + final formDataMap = {} + ..addEntries(data.fields) + ..addEntries(data.files); + _printMapAsTable(_logPrintRequest, formDataMap, + header: 'Form data | ${data.boundary}'); + } else { + _printBlock(_logPrintRequest, data.toString()); + } + } + } + super.onRequest(options, handler); + } + + @override + void onError(DioError err, ErrorInterceptorHandler handler) { + if (error) { + if (err.type == DioErrorType.response) { + final uri = err.response?.requestOptions.uri; + _printBoxed( + _logPrintError, + header: + 'DioError ║ Status: ${err.response?.statusCode} ${err.response?.statusMessage}', + text: uri.toString(), + ); + if (err.response != null && err.response?.data != null) { + _logPrintError('╔ ${err.type.toString()}'); + _printResponse(_logPrintError, err.response!); + } + _printLine(_logPrintError, '╚'); + _logPrintError(''); + } else { + _printBoxed( + _logPrintError, + header: 'DioError ║ ${err.type}', + text: err.message, + ); + _printRequestHeader(_logPrintError, err.requestOptions); + } + } + super.onError(err, handler); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + _printResponseHeader(_logPrintResponse, response); + if (responseHeader) { + final responseHeaders = {}; + response.headers + .forEach((k, list) => responseHeaders[k] = list.toString()); + _printMapAsTable(_logPrintResponse, responseHeaders, header: 'Headers'); + } + + if (responseBody) { + _logPrintResponse('╔ Body'); + _logPrintResponse('║'); + _printResponse(_logPrintResponse, response); + _logPrintResponse('║'); + _printLine(_logPrintResponse, '╚'); + } + super.onResponse(response, handler); + } + + void _printBoxed( + void Function(Object) logPrint, { + String? header, + String? text, + }) { + logPrint(''); + logPrint('â•”â•Ŗ $header'); + logPrint('║ $text'); + _printLine(logPrint, '╚'); + } + + void _printResponse(void Function(Object) logPrint, Response response) { + if (response.data != null) { + if (response.data is Map) { + _printPrettyMap(logPrint, response.data as Map); + } else if (response.data is List) { + logPrint('║${_indent()}['); + _printList(logPrint, response.data as List); + logPrint('║${_indent()}['); + } else { + _printBlock(logPrint, response.data.toString()); + } + } + } + + void _printResponseHeader(void Function(Object) logPrint, Response response) { + final uri = response.requestOptions.uri; + final method = response.requestOptions.method; + _printBoxed( + logPrint, + header: + 'Response ║ $method ║ Status: ${response.statusCode} ${response.statusMessage}', + text: uri.toString(), + ); + } + + void _printRequestHeader( + void Function(Object) logPrint, RequestOptions options) { + final uri = options.uri; + final method = options.method; + _printBoxed(logPrint, header: 'Request ║ $method ', text: uri.toString()); + } + + void _printLine(void Function(Object) logPrint, + [String pre = '', String suf = '╝']) => + logPrint('$pre${'═' * maxWidth}$suf'); + + void _printKV(void Function(Object) logPrint, String? key, Object? v) { + final pre = '╟ $key: '; + final msg = v.toString(); + + if (pre.length + msg.length > maxWidth) { + logPrint(pre); + _printBlock(logPrint, msg); + } else { + logPrint('$pre$msg'); + } + } + + void _printBlock(void Function(Object) logPrint, String msg) { + final lines = (msg.length / maxWidth).ceil(); + for (var i = 0; i < lines; ++i) { + logPrint((i >= 0 ? '║ ' : '') + + msg.substring(i * maxWidth, + math.min(i * maxWidth + maxWidth, msg.length))); + } + } + + String _indent([int tabCount = initialTab]) => tabStep * tabCount; + + void _printPrettyMap( + void Function(Object) logPrint, + Map data, { + int tabs = initialTab, + bool isListItem = false, + bool isLast = false, + }) { + var _tabs = tabs; + final isRoot = _tabs == initialTab; + final initialIndent = _indent(_tabs); + _tabs++; + + if (isRoot || isListItem) logPrint('║$initialIndent{'); + + data.keys.toList().asMap().forEach((index, dynamic key) { + final isLast = index == data.length - 1; + dynamic value = data[key]; + if (value is String) { + value = '"${value.toString().replaceAll(RegExp(r'(\r|\n)+'), " ")}"'; + } + if (value is Map) { + if (compact) { + logPrint('║${_indent(_tabs)} $key: $value${!isLast ? ',' : ''}'); + } else { + logPrint('║${_indent(_tabs)} $key: {'); + _printPrettyMap(logPrint, value, tabs: _tabs); + } + } else if (value is List) { + if (compact) { + logPrint('║${_indent(_tabs)} $key: ${value.toString()}'); + } else { + logPrint('║${_indent(_tabs)} $key: ['); + _printList(logPrint, value, tabs: _tabs); + logPrint('║${_indent(_tabs)} ]${isLast ? '' : ','}'); + } + } else { + final msg = value.toString().replaceAll('\n', ''); + final indent = _indent(_tabs); + final linWidth = maxWidth - indent.length; + if (msg.length + indent.length > linWidth) { + final lines = (msg.length / linWidth).ceil(); + for (var i = 0; i < lines; ++i) { + logPrint('║${_indent(_tabs)} ${msg.substring( + i * linWidth, + math.min(i * linWidth + linWidth, msg.length), + )}'); + } + } else { + logPrint('║${_indent(_tabs)} $key: $msg${!isLast ? ',' : ''}'); + } + } + }); + + logPrint('║$initialIndent}${isListItem && !isLast ? ',' : ''}'); + } + + void _printList( + void Function(Object) logPrint, + List list, { + int tabs = initialTab, + }) { + list.asMap().forEach((i, dynamic e) { + final isLast = i == list.length - 1; + if (e is Map) { + if (compact) { + logPrint('║${_indent(tabs)} $e${!isLast ? ',' : ''}'); + } else { + _printPrettyMap(logPrint, e, + tabs: tabs + 1, isListItem: true, isLast: isLast); + } + } else { + logPrint('║${_indent(tabs + 2)} $e${isLast ? '' : ','}'); + } + }); + } + + void _printMapAsTable( + void Function(Object) logPrint, + Map? map, { + String? header, + }) { + if (map == null || map.isEmpty) return; + logPrint('╔ $header '); + map.forEach((dynamic key, dynamic value) => + _printKV(logPrint, key.toString(), value)); + _printLine(logPrint, '╚'); + } + + void _logPrintRequest(Object object) => + logPrint(InterceptStep.request, object); + + void _logPrintResponse(Object object) => + logPrint(InterceptStep.response, object); + + void _logPrintError(Object object) => logPrint(InterceptStep.error, object); +} diff --git a/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart b/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart new file mode 100644 index 00000000..a8ee0988 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart @@ -0,0 +1,21 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/error/error.dart'; + +/// Error class specific to StreamChat and Dio +class StreamChatDioError extends DioError { + /// Initialize a stream chat dio error + StreamChatDioError({ + required this.error, + required RequestOptions requestOptions, + Response? response, + DioErrorType type = DioErrorType.other, + }) : super( + error: error, + requestOptions: requestOptions, + response: response, + type: type, + ); + + @override + final StreamChatNetworkError error; +} diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client.dart b/packages/stream_chat/lib/src/core/http/stream_http_client.dart new file mode 100644 index 00000000..406138c3 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/stream_http_client.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/location.dart'; +import 'package:stream_chat/version.dart'; + +part 'stream_http_client_options.dart'; + +/// This is where we configure the base url, headers, +/// query parameters and convenient methods for http verbs with error parsing. +class StreamHttpClient { + /// [StreamHttpClient] constructor + StreamHttpClient( + this.apiKey, { + Dio? dio, + StreamHttpClientOptions? options, + TokenManager? tokenManager, + ConnectionIdManager? connectionIdManager, + Logger? logger, + }) : _options = options ?? const StreamHttpClientOptions(), + httpClient = dio ?? Dio() { + httpClient + ..options.baseUrl = _options.baseUrl + ..options.receiveTimeout = _options.receiveTimeout.inMilliseconds + ..options.connectTimeout = _options.connectTimeout.inMilliseconds + ..options.queryParameters = {'api_key': apiKey} + ..options.headers = { + 'Content-Type': 'application/json', + 'X-Stream-Client': _options.userAgent, + 'Content-Encoding': 'application/gzip', + } + ..interceptors.addAll([ + if (tokenManager != null) AuthInterceptor(this, tokenManager), + if (connectionIdManager != null) + ConnectionIdInterceptor(connectionIdManager), + if (logger != null && logger.level != Level.OFF) + LoggingInterceptor( + requestHeader: true, + logPrint: (step, message) { + switch (step) { + case InterceptStep.request: + return logger.info(message); + case InterceptStep.response: + return logger.info(message); + case InterceptStep.error: + return logger.severe(message); + } + }, + ), + ]); + } + + /// Your project Stream Chat api key. + /// Find your API keys here https://getstream.io/dashboard/ + final String apiKey; + + /// Your project Stream Chat ClientOptions + final StreamHttpClientOptions _options; + + /// [Dio] httpClient + /// It's been chosen because it's easy to use + /// and supports interesting features out of the box + /// (Interceptors, Global configuration, FormData, File downloading etc.) + @visibleForTesting + final Dio httpClient; + + /// Lock the current [StreamHttpClient] instance. + /// + /// [StreamHttpClient] will enqueue the incoming request tasks instead + /// send them directly when [interceptor.requestOptions] is locked. + void lock() => httpClient.lock(); + + /// Unlock the current [StreamHttpClient] instance. + /// + /// [StreamHttpClient] instance dequeue the request task。 + void unlock() => httpClient.unlock(); + + /// Clear the current [StreamHttpClient] instance waiting queue. + void clear() => httpClient.clear(); + + /// Shuts down the [StreamHttpClient]. + /// + /// If [force] is `false` the [StreamHttpClient] will be kept alive + /// until all active connections are done. If [force] is `true` any active + /// connections will be closed to immediately release all resources. These + /// closed connections will receive an error event to indicate that the client + /// was shut down. In both cases trying to establish a new connection after + /// calling [close] will throw an exception. + void close({bool force = false}) => httpClient.close(force: force); + + StreamChatNetworkError _parseError(DioError err) { + StreamChatNetworkError error; + // locally thrown dio error + if (err is StreamChatDioError) { + error = err.error; + } else { + // real network request dio error + error = StreamChatNetworkError.fromDioError(err); + } + return error..stackTrace = err.stackTrace; + } + + /// Handy method to make http GET request with error parsing. + Future> get( + String path, { + Map? queryParameters, + Map? headers, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.get( + path, + queryParameters: queryParameters, + options: Options(headers: headers), + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http POST request with error parsing. + Future> post( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.post( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http DELETE request with error parsing. + Future> delete( + String path, { + Map? queryParameters, + Map? headers, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.delete( + path, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PATCH request with error parsing. + Future> patch( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.patch( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PUT request with error parsing. + Future> put( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.put( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to post files with error parsing. + Future> postFile( + String path, + MultipartFile file, { + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + final formData = FormData.fromMap({'file': file}); + final response = await post( + path, + data: formData, + queryParameters: queryParameters, + headers: headers, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } + + /// Handy method to make generic http request with error parsing. + Future> request( + String path, { + Object? data, + Map? queryParameters, + Options? options, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.request( + path, + data: data, + queryParameters: queryParameters, + options: options, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } +} diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart new file mode 100644 index 00000000..faad46a1 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart @@ -0,0 +1,39 @@ +part of 'stream_http_client.dart'; + +const _defaultBaseURL = 'https://chat-us-east-1.stream-io-api.com'; + +/// Client options to modify [StreamHttpClient] +class StreamHttpClientOptions { + /// Instantiates a new [StreamHttpClientOptions] + const StreamHttpClientOptions({ + String? baseUrl, + this.location, + this.connectTimeout = const Duration(seconds: 6), + this.receiveTimeout = const Duration(seconds: 6), + }) : _baseUrl = baseUrl ?? _defaultBaseURL; + + final String _baseUrl; + + /// base url to use with client. + String get baseUrl { + if (location == null) return _baseUrl; + const serviceName = 'chat'; + final locationName = location!.name; + const baseDomainName = 'stream-io-api.com'; + return 'https://$serviceName-proxy-$locationName.$baseDomainName'; + } + + /// data center to use with client + final Location? location; + + /// connect timeout, default to 6s + final Duration connectTimeout; + + /// received timeout, default to 6s + final Duration receiveTimeout; + + /// Get the current user agent + String get userAgent => 'stream-chat-dart-client-' + '${CurrentPlatform.name}-' + '${PACKAGE_VERSION.split('+')[0]}'; +} diff --git a/packages/stream_chat/lib/src/core/http/token.dart b/packages/stream_chat/lib/src/core/http/token.dart new file mode 100644 index 00000000..7d746286 --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/token.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:jose/jose.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/utils.dart'; + +/// A function which can be used to request a Stream Chat API token from your +/// own backend server +typedef GuestTokenProvider = Future Function(User user); + +/// Authentication type +enum AuthType { + /// JWT token + jwt, + + /// Anonymous user + anonymous, +} + +/// Extension for returning the AuthType as a string +extension AuthTypeX on AuthType { + /// Returns the AuthType as a string + String get raw => { + AuthType.jwt: 'jwt', + AuthType.anonymous: 'anonymous', + }[this]!; +} + +/// Token designed to store the JWT and the user it is related to. +class Token extends Equatable { + const Token._({ + required this.rawValue, + required this.userId, + required this.authType, + }); + + /// The token that can be used when user is unknown. + /// Is used by `anonymous` token provider. + factory Token.anonymous({String? userId}) => Token._( + rawValue: '', + userId: userId ?? randomId(), + authType: AuthType.anonymous, + ); + + /// Creates a [Token] instance from the provided [rawValue] if it's valid. + factory Token.fromRawValue(String rawValue) { + final jwtBody = JsonWebToken.unverified(rawValue); + final userId = jwtBody.claims.getTyped('user_id'); + assert( + userId != null, + 'Invalid `token`, It should contain `user_id`', + ); + return Token._(rawValue: rawValue, userId: userId!, authType: AuthType.jwt); + } + + /// The token which can be used during the development. + /// Is used by `development(userId:)` token provider. + factory Token.development(String userId) { + const devSignature = 'devtoken'; + const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + final payload = json.encode({'user_id': userId}); + final payloadBytes = utf8.encode(payload); + final payloadB64 = base64.encode(payloadBytes); + final jwt = '$header.$payloadB64.$devSignature'; + return Token._(rawValue: jwt, userId: userId, authType: AuthType.jwt); + } + + /// The token which designed to be used for guest users. + static Future guest(User user, GuestTokenProvider provider) async { + final rawToken = await provider(user); + return Token.fromRawValue(rawToken); + } + + /// Authentication type of this token + final AuthType authType; + + /// String value of the token + final String rawValue; + + /// User id associated with this token + final String userId; + + @override + List get props => [authType, rawValue, userId]; +} diff --git a/packages/stream_chat/lib/src/core/http/token_manager.dart b/packages/stream_chat/lib/src/core/http/token_manager.dart new file mode 100644 index 00000000..e5af0ddc --- /dev/null +++ b/packages/stream_chat/lib/src/core/http/token_manager.dart @@ -0,0 +1,81 @@ +import 'package:stream_chat/src/core/http/token.dart'; + +/// A function which can be used to request a Stream Chat API token from your +/// own backend server. +/// Function requires a single [userId]. +typedef TokenProvider = Future Function(String userId); + +/// Handles common token operations +class TokenManager { + /// Initialize a new token manager + TokenManager({ + String? userId, + Token? token, + TokenProvider? tokenProvider, + }) : _userId = userId, + _token = token, + _provider = tokenProvider; + + String? _type; + Token? _token; + + TokenProvider? _provider; + + String? _userId; + + /// User id to which this TokenManager is configured to + String? get userId => _userId; + + /// True if it's a static token + bool get isStatic => _type == 'static'; + + /// Set a token or a token provider + Future setTokenOrProvider( + String userId, { + Token? token, + TokenProvider? provider, + }) async { + assert(() { + if (token == null && provider == null) { + throw AssertionError('Provide at-least token or provider'); + } + if (token != null && provider != null) { + throw AssertionError("Can't set both token and provider"); + } + return true; + }(), ''); + + _userId = userId; + + if (token != null) { + _type = 'static'; + _token = token; + } + if (provider != null) { + _type = 'provider'; + _provider = provider; + } + + return loadToken(); + } + + /// Returns the token refreshing the existing one if [refresh] is true + Future loadToken({bool refresh = false}) async { + assert( + _userId != null && _type != null, + 'Please call `setTokenOrProvider` before calling `loadToken`', + ); + if (refresh || _token == null) { + final rawValue = await _provider!(_userId!); + _token = Token.fromRawValue(rawValue); + } + return _token!; + } + + /// Resets the token manager + void reset() { + _userId = null; + _token = null; + _provider = null; + } +} diff --git a/packages/stream_chat/lib/src/models/action.dart b/packages/stream_chat/lib/src/core/models/action.dart similarity index 100% rename from packages/stream_chat/lib/src/models/action.dart rename to packages/stream_chat/lib/src/core/models/action.dart diff --git a/packages/stream_chat/lib/src/models/action.g.dart b/packages/stream_chat/lib/src/core/models/action.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/action.g.dart rename to packages/stream_chat/lib/src/core/models/action.g.dart diff --git a/packages/stream_chat/lib/src/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart similarity index 91% rename from packages/stream_chat/lib/src/models/attachment.dart rename to packages/stream_chat/lib/src/core/models/attachment.dart index b68a1bae..8973aee2 100644 --- a/packages/stream_chat/lib/src/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -2,9 +2,9 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/action.dart'; -import 'package:stream_chat/src/models/attachment_file.dart'; -import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/core/models/action.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; import 'package:uuid/uuid.dart'; part 'attachment.g.dart'; @@ -49,11 +49,11 @@ class Attachment extends Equatable { /// Create a new instance from a json factory Attachment.fromJson(Map json) => _$AttachmentFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// Create a new instance from a db data factory Attachment.fromData(Map json) => - _$AttachmentFromJson(Serialization.moveToExtraDataFromRoot( + _$AttachmentFromJson(Serializer.moveToExtraDataFromRoot( json, topLevelFields + dbSpecificTopLevelFields)); ///The attachment type based on the URL resource. This can be: audio, @@ -122,7 +122,7 @@ class Attachment extends Equatable { final String id; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const topLevelFields = [ 'type', 'title_link', @@ -145,7 +145,7 @@ class Attachment extends Equatable { ]; /// Known db specific top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const dbSpecificTopLevelFields = [ 'id', 'upload_state', @@ -154,12 +154,12 @@ class Attachment extends Equatable { /// Serialize to json Map toJson() => - Serialization.moveFromExtraDataToRoot(_$AttachmentToJson(this)) + Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)) ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); /// Serialize to db data Map toData() => - Serialization.moveFromExtraDataToRoot(_$AttachmentToJson(this)); + Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)); Attachment copyWith({ String? id, diff --git a/packages/stream_chat/lib/src/models/attachment.g.dart b/packages/stream_chat/lib/src/core/models/attachment.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/attachment.g.dart rename to packages/stream_chat/lib/src/core/models/attachment.g.dart diff --git a/packages/stream_chat/lib/src/models/attachment_file.dart b/packages/stream_chat/lib/src/core/models/attachment_file.dart similarity index 74% rename from packages/stream_chat/lib/src/models/attachment_file.dart rename to packages/stream_chat/lib/src/core/models/attachment_file.dart index 8dbb70ad..7bff5806 100644 --- a/packages/stream_chat/lib/src/models/attachment_file.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.dart @@ -1,9 +1,13 @@ import 'dart:typed_data'; +import 'package:dio/dio.dart' show MultipartFile; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:meta/meta.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; part 'attachment_file.freezed.dart'; + part 'attachment_file.g.dart'; /// Union class to hold various [UploadState] of a attachment. @@ -58,14 +62,18 @@ String? _toString(Uint8List? bytes) { @JsonSerializable() class AttachmentFile { /// Creates a new [AttachmentFile] instance. - const AttachmentFile({ + AttachmentFile({ required this.size, this.path, this.name, this.bytes, - }) : assert( + }) : assert( path != null || bytes != null, 'Either path or bytes should be != null', + ), + assert( + !CurrentPlatform.isWeb || bytes != null, + 'File by path is not supported in web, Please provide bytes', ); /// Create a new instance from a json @@ -95,4 +103,27 @@ class AttachmentFile { /// Serialize to json Map toJson() => _$AttachmentFileToJson(this); + + /// Converts this into a [MultipartFile] + Future toMultipartFile() async { + final filename = path?.split('/').last ?? name; + final mimeType = filename?.mimeType; + + late MultipartFile multiPartFile; + + if (CurrentPlatform.isWeb) { + multiPartFile = MultipartFile.fromBytes( + bytes!, + filename: filename, + contentType: mimeType, + ); + } else { + multiPartFile = await MultipartFile.fromFile( + path!, + filename: filename, + contentType: mimeType, + ); + } + return multiPartFile; + } } diff --git a/packages/stream_chat/lib/src/models/attachment_file.freezed.dart b/packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart similarity index 100% rename from packages/stream_chat/lib/src/models/attachment_file.freezed.dart rename to packages/stream_chat/lib/src/core/models/attachment_file.freezed.dart diff --git a/packages/stream_chat/lib/src/models/attachment_file.g.dart b/packages/stream_chat/lib/src/core/models/attachment_file.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/attachment_file.g.dart rename to packages/stream_chat/lib/src/core/models/attachment_file.g.dart diff --git a/packages/stream_chat/lib/src/models/channel_config.dart b/packages/stream_chat/lib/src/core/models/channel_config.dart similarity index 97% rename from packages/stream_chat/lib/src/models/channel_config.dart rename to packages/stream_chat/lib/src/core/models/channel_config.dart index 9ba36e4f..2d5182b3 100644 --- a/packages/stream_chat/lib/src/models/channel_config.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.dart @@ -1,5 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/command.dart'; +import 'package:stream_chat/src/core/models/command.dart'; part 'channel_config.g.dart'; diff --git a/packages/stream_chat/lib/src/models/channel_config.g.dart b/packages/stream_chat/lib/src/core/models/channel_config.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/channel_config.g.dart rename to packages/stream_chat/lib/src/core/models/channel_config.g.dart diff --git a/packages/stream_chat/lib/src/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart similarity index 80% rename from packages/stream_chat/lib/src/models/channel_model.dart rename to packages/stream_chat/lib/src/core/models/channel_model.dart index 41230bf7..37e587ff 100644 --- a/packages/stream_chat/lib/src/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_config.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'channel_model.g.dart'; @@ -37,7 +37,7 @@ class ChannelModel { /// Create a new instance from a json factory ChannelModel.fromJson(Map json) => _$ChannelModelFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// The id of this channel final String id; @@ -46,15 +46,15 @@ class ChannelModel { final String type; /// The cid of this channel - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String cid; /// The channel configuration data - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final ChannelConfig config; /// The user that created this channel - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final User? createdBy; /// True if this channel is frozen @@ -62,24 +62,23 @@ class ChannelModel { final bool frozen; /// The date of the last message - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime? lastMessageAt; /// The date of channel creation - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// The date of the last channel update - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime updatedAt; /// The date of channel deletion - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime? deletedAt; /// The count of this channel members - @JsonKey( - includeIfNull: false, toJson: Serialization.readOnly, defaultValue: 0) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly, defaultValue: 0) final int memberCount; /// Map of custom channel extraData @@ -90,11 +89,11 @@ class ChannelModel { final Map extraData; /// The team the channel belongs to - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String? team; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const topLevelFields = [ 'id', 'type', @@ -115,7 +114,7 @@ class ChannelModel { extraData.containsKey('name') ? extraData['name']! as String : cid; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$ChannelModelToJson(this), ); diff --git a/packages/stream_chat/lib/src/models/channel_model.g.dart b/packages/stream_chat/lib/src/core/models/channel_model.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/channel_model.g.dart rename to packages/stream_chat/lib/src/core/models/channel_model.g.dart diff --git a/packages/stream_chat/lib/src/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart similarity index 87% rename from packages/stream_chat/lib/src/models/channel_state.dart rename to packages/stream_chat/lib/src/core/models/channel_state.dart index 9d18f434..998efa98 100644 --- a/packages/stream_chat/lib/src/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'channel_state.g.dart'; diff --git a/packages/stream_chat/lib/src/models/channel_state.g.dart b/packages/stream_chat/lib/src/core/models/channel_state.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/channel_state.g.dart rename to packages/stream_chat/lib/src/core/models/channel_state.g.dart diff --git a/packages/stream_chat/lib/src/models/command.dart b/packages/stream_chat/lib/src/core/models/command.dart similarity index 100% rename from packages/stream_chat/lib/src/models/command.dart rename to packages/stream_chat/lib/src/core/models/command.dart diff --git a/packages/stream_chat/lib/src/models/command.g.dart b/packages/stream_chat/lib/src/core/models/command.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/command.g.dart rename to packages/stream_chat/lib/src/core/models/command.g.dart diff --git a/packages/stream_chat/lib/src/models/device.dart b/packages/stream_chat/lib/src/core/models/device.dart similarity index 100% rename from packages/stream_chat/lib/src/models/device.dart rename to packages/stream_chat/lib/src/core/models/device.dart diff --git a/packages/stream_chat/lib/src/models/device.g.dart b/packages/stream_chat/lib/src/core/models/device.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/device.g.dart rename to packages/stream_chat/lib/src/core/models/device.g.dart diff --git a/packages/stream_chat/lib/src/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart similarity index 86% rename from packages/stream_chat/lib/src/models/event.dart rename to packages/stream_chat/lib/src/core/models/event.dart index b4ee9c1d..26831555 100644 --- a/packages/stream_chat/lib/src/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; import 'package:stream_chat/stream_chat.dart'; part 'event.g.dart'; @@ -10,11 +10,11 @@ part 'event.g.dart'; @JsonSerializable() class Event { /// Constructor used for json serialization - const Event({ - this.type, + Event({ + this.type = 'local.event', this.cid, this.connectionId, - this.createdAt, + DateTime? createdAt, this.me, this.user, this.message, @@ -29,18 +29,18 @@ class Event { this.parentId, this.extraData = const {}, this.isLocal = true, - }); + }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); /// Create a new instance from a json factory Event.fromJson(Map json) => - _$EventFromJson(Serialization.moveToExtraDataFromRoot( + _$EventFromJson(Serializer.moveToExtraDataFromRoot( json, topLevelFields, )); /// The type of the event /// [EventType] contains some predefined constant types - final String? type; + final String type; /// The channel cid to which the event belongs final String? cid; @@ -55,7 +55,7 @@ class Event { final String? connectionId; /// The date of creation of the event - final DateTime? createdAt; + final DateTime createdAt; /// User object of the health check user final OwnUser? me; @@ -96,7 +96,7 @@ class Event { final Map extraData; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static final topLevelFields = [ 'type', 'cid', @@ -118,7 +118,7 @@ class Event { ]; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$EventToJson(this), ); @@ -160,11 +160,14 @@ class Event { channelType: channelType ?? this.channelType, parentId: parentId ?? this.parentId, extraData: extraData ?? this.extraData, + isLocal: isLocal, ); } /// The channel embedded in the event object -@JsonSerializable() +@JsonSerializable( + createToJson: false, +) class EventChannel extends ChannelModel { /// Constructor used for json serialization EventChannel({ @@ -198,7 +201,7 @@ class EventChannel extends ChannelModel { /// Create a new instance from a json factory EventChannel.fromJson(Map json) => - _$EventChannelFromJson(Serialization.moveToExtraDataFromRoot( + _$EventChannelFromJson(Serializer.moveToExtraDataFromRoot( json, topLevelFields, )); @@ -207,15 +210,9 @@ class EventChannel extends ChannelModel { final List? members; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static final topLevelFields = [ 'members', ...ChannelModel.topLevelFields, ]; - - /// Serialize to json - @override - Map toJson() => Serialization.moveFromExtraDataToRoot( - _$EventChannelToJson(this), - ); } diff --git a/packages/stream_chat/lib/src/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart similarity index 77% rename from packages/stream_chat/lib/src/models/event.g.dart rename to packages/stream_chat/lib/src/core/models/event.g.dart index aba142f8..5247af17 100644 --- a/packages/stream_chat/lib/src/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -8,7 +8,7 @@ part of 'event.dart'; Event _$EventFromJson(Map json) { return Event( - type: json['type'] as String?, + type: json['type'] as String, cid: json['cid'] as String?, connectionId: json['connection_id'] as String?, createdAt: json['created_at'] == null @@ -49,7 +49,7 @@ Map _$EventToJson(Event instance) => { 'channel_id': instance.channelId, 'channel_type': instance.channelType, 'connection_id': instance.connectionId, - 'created_at': instance.createdAt?.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), 'me': instance.me?.toJson(), 'user': instance.user?.toJson(), 'message': instance.message?.toJson(), @@ -89,29 +89,3 @@ EventChannel _$EventChannelFromJson(Map json) { extraData: json['extra_data'] as Map? ?? {}, ); } - -Map _$EventChannelToJson(EventChannel instance) { - final val = { - 'id': instance.id, - 'type': instance.type, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('cid', readonly(instance.cid)); - writeNotNull('config', readonly(instance.config)); - writeNotNull('created_by', readonly(instance.createdBy)); - val['frozen'] = instance.frozen; - writeNotNull('last_message_at', readonly(instance.lastMessageAt)); - writeNotNull('created_at', readonly(instance.createdAt)); - writeNotNull('updated_at', readonly(instance.updatedAt)); - writeNotNull('deleted_at', readonly(instance.deletedAt)); - writeNotNull('member_count', readonly(instance.memberCount)); - val['extra_data'] = instance.extraData; - val['members'] = instance.members?.map((e) => e.toJson()).toList(); - return val; -} diff --git a/packages/stream_chat/lib/src/models/filter.dart b/packages/stream_chat/lib/src/core/models/filter.dart similarity index 100% rename from packages/stream_chat/lib/src/models/filter.dart rename to packages/stream_chat/lib/src/core/models/filter.dart diff --git a/packages/stream_chat/lib/src/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart similarity index 87% rename from packages/stream_chat/lib/src/models/member.dart rename to packages/stream_chat/lib/src/core/models/member.dart index 49df170c..5c9bc95c 100644 --- a/packages/stream_chat/lib/src/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -1,12 +1,13 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'member.g.dart'; /// The class that contains the information about the user membership /// in a channel @JsonSerializable() -class Member { +class Member extends Equatable { /// Constructor used for json serialization Member({ this.user, @@ -98,4 +99,19 @@ class Member { /// Serialize to json Map toJson() => _$MemberToJson(this); + + @override + List get props => [ + user, + inviteAcceptedAt, + inviteRejectedAt, + invited, + role, + userId, + isModerator, + banned, + shadowBanned, + createdAt, + updatedAt, + ]; } diff --git a/packages/stream_chat/lib/src/models/member.g.dart b/packages/stream_chat/lib/src/core/models/member.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/member.g.dart rename to packages/stream_chat/lib/src/core/models/member.g.dart diff --git a/packages/stream_chat/lib/src/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart similarity index 87% rename from packages/stream_chat/lib/src/models/message.dart rename to packages/stream_chat/lib/src/core/models/message.dart index 6df17497..d394dbdd 100644 --- a/packages/stream_chat/lib/src/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -1,9 +1,9 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/attachment.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/attachment.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:stream_chat/src/core/models/user.dart'; import 'package:uuid/uuid.dart'; part 'message.g.dart'; @@ -80,7 +80,7 @@ class Message extends Equatable { /// Create a new instance from a json factory Message.fromJson(Map json) => _$MessageFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// The message ID. This is either created by Stream or set client side when /// the message is added. @@ -96,7 +96,7 @@ class Message extends Equatable { /// The message type @JsonKey( includeIfNull: false, - toJson: Serialization.readOnly, + toJson: Serializer.readOnly, defaultValue: 'regular', ) final String type; @@ -111,43 +111,43 @@ class Message extends Equatable { /// The list of user mentioned in the message @JsonKey( - toJson: Serialization.userIds, + toJson: User.toIds, defaultValue: [], ) final List mentionedUsers; /// A map describing the count of number of every reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final Map? reactionCounts; /// A map describing the count of score of every reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final Map? reactionScores; /// The latest reactions to the message created by any user. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final List? latestReactions; /// The reactions added to the message by the current user. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final List? ownReactions; /// The ID of the parent message, if the message is a thread reply. final String? parentId; /// A quoted reply message - @JsonKey(toJson: Serialization.readOnly) + @JsonKey(toJson: Serializer.readOnly) final Message? quotedMessage; /// The ID of the quoted message, if the message is a quoted reply. final String? quotedMessageId; /// Reserved field indicating the number of replies for this message. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final int? replyCount; /// Reserved field indicating the thread participants for this message. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final List? threadParticipants; /// Check if this message needs to show in the channel. @@ -160,25 +160,25 @@ class Message extends Equatable { /// If true the message is shadowed @JsonKey( includeIfNull: false, - toJson: Serialization.readOnly, + toJson: Serializer.readOnly, defaultValue: false, ) final bool shadowed; /// A used command name. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String? command; /// Reserved field indicating when the message was created. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// Reserved field indicating when the message was updated last time. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime updatedAt; /// User who sent the message - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final User? user; /// If true the message is pinned @@ -186,7 +186,7 @@ class Message extends Equatable { final bool pinned; /// Reserved field indicating when the message was pinned - @JsonKey(toJson: Serialization.readOnly) + @JsonKey(toJson: Serializer.readOnly) final DateTime? pinnedAt; /// Reserved field indicating when the message will expire @@ -195,7 +195,7 @@ class Message extends Equatable { final DateTime? pinExpires; /// Reserved field indicating who pinned the message - @JsonKey(toJson: Serialization.readOnly) + @JsonKey(toJson: Serializer.readOnly) final User? pinnedBy; /// Message custom extraData @@ -215,11 +215,11 @@ class Message extends Equatable { bool get isEphemeral => type == 'ephemeral'; /// Reserved field indicating when the message was deleted. - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime? deletedAt; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const topLevelFields = [ 'id', 'text', @@ -251,7 +251,7 @@ class Message extends Equatable { ]; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$MessageToJson(this), ); @@ -403,14 +403,14 @@ class TranslatedMessage extends Message { /// Create a new instance from a json factory TranslatedMessage.fromJson(Map json) => _$TranslatedMessageFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields), + Serializer.moveToExtraDataFromRoot(json, topLevelFields), ); /// A Map of final Map? i18n; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static final topLevelFields = [ 'i18n', ...Message.topLevelFields, @@ -418,7 +418,7 @@ class TranslatedMessage extends Message { /// Serialize to json @override - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$TranslatedMessageToJson(this), ); } diff --git a/packages/stream_chat/lib/src/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart similarity index 98% rename from packages/stream_chat/lib/src/models/message.g.dart rename to packages/stream_chat/lib/src/core/models/message.g.dart index 8792035a..c49e2a6e 100644 --- a/packages/stream_chat/lib/src/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -84,7 +84,7 @@ Map _$MessageToJson(Message instance) { writeNotNull('type', readonly(instance.type)); val['attachments'] = instance.attachments.map((e) => e.toJson()).toList(); - val['mentioned_users'] = Serialization.userIds(instance.mentionedUsers); + val['mentioned_users'] = User.toIds(instance.mentionedUsers); writeNotNull('reaction_counts', readonly(instance.reactionCounts)); writeNotNull('reaction_scores', readonly(instance.reactionScores)); writeNotNull('latest_reactions', readonly(instance.latestReactions)); diff --git a/packages/stream_chat/lib/src/models/mute.dart b/packages/stream_chat/lib/src/core/models/mute.dart similarity index 56% rename from packages/stream_chat/lib/src/models/mute.dart rename to packages/stream_chat/lib/src/core/models/mute.dart index 3ba26230..857b34a1 100644 --- a/packages/stream_chat/lib/src/models/mute.dart +++ b/packages/stream_chat/lib/src/core/models/mute.dart @@ -1,12 +1,12 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; part 'mute.g.dart'; /// The class that contains the information about a muted user -@JsonSerializable() +@JsonSerializable(createToJson: false) class Mute { /// Constructor used for json serialization Mute({ @@ -20,21 +20,18 @@ class Mute { factory Mute.fromJson(Map json) => _$MuteFromJson(json); /// The user that performed the muting action - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final User user; /// The target user - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final ChannelModel channel; /// The date in which the use was muted - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// The date of the last update - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime updatedAt; - - /// Serialize to json - Map toJson() => _$MuteToJson(this); } diff --git a/packages/stream_chat/lib/src/models/mute.g.dart b/packages/stream_chat/lib/src/core/models/mute.g.dart similarity index 57% rename from packages/stream_chat/lib/src/models/mute.g.dart rename to packages/stream_chat/lib/src/core/models/mute.g.dart index e77b8707..b847a109 100644 --- a/packages/stream_chat/lib/src/models/mute.g.dart +++ b/packages/stream_chat/lib/src/core/models/mute.g.dart @@ -14,19 +14,3 @@ Mute _$MuteFromJson(Map json) { updatedAt: DateTime.parse(json['updated_at'] as String), ); } - -Map _$MuteToJson(Mute instance) { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('user', readonly(instance.user)); - writeNotNull('channel', readonly(instance.channel)); - writeNotNull('created_at', readonly(instance.createdAt)); - writeNotNull('updated_at', readonly(instance.updatedAt)); - return val; -} diff --git a/packages/stream_chat/lib/src/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart similarity index 61% rename from packages/stream_chat/lib/src/models/own_user.dart rename to packages/stream_chat/lib/src/core/models/own_user.dart index a93af19a..7ceffbfa 100644 --- a/packages/stream_chat/lib/src/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -1,14 +1,14 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/device.dart'; -import 'package:stream_chat/src/models/mute.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/device.dart'; +import 'package:stream_chat/src/core/models/mute.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; part 'own_user.g.dart'; /// The class that defines the own user model /// This object can be found in [Event] -@JsonSerializable() +@JsonSerializable(createToJson: false) class OwnUser extends User { /// Constructor used for json serialization OwnUser({ @@ -38,40 +38,42 @@ class OwnUser extends User { /// Create a new instance from a json factory OwnUser.fromJson(Map json) => _$OwnUserFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + Serializer.moveToExtraDataFromRoot(json, topLevelFields)); + + /// Create a new instance from [User] object + factory OwnUser.fromUser(User user) => OwnUser( + id: user.id, + role: user.role, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastActive: user.lastActive, + online: user.online, + banned: user.banned, + extraData: user.extraData, + ); /// List of user devices - @JsonKey( - includeIfNull: false, - toJson: Serialization.readOnly, - defaultValue: []) + @JsonKey(includeIfNull: false, defaultValue: []) final List devices; /// List of users muted by the user - @JsonKey( - includeIfNull: false, - toJson: Serialization.readOnly, - defaultValue: []) + @JsonKey(includeIfNull: false, defaultValue: []) final List mutes; /// List of users muted by the user - @JsonKey( - includeIfNull: false, - toJson: Serialization.readOnly, - defaultValue: []) + @JsonKey(includeIfNull: false, defaultValue: []) final List channelMutes; /// Total unread messages by the user - @JsonKey( - includeIfNull: false, toJson: Serialization.readOnly, defaultValue: 0) + @JsonKey(includeIfNull: false, defaultValue: 0) final int totalUnreadCount; /// Total unread channels by the user - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false) final int? unreadChannels; /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static final topLevelFields = [ 'devices', 'mutes', @@ -80,10 +82,4 @@ class OwnUser extends User { 'channel_mutes', ...User.topLevelFields, ]; - - /// Serialize to json - @override - Map toJson() => Serialization.moveFromExtraDataToRoot( - _$OwnUserToJson(this), - ); } diff --git a/packages/stream_chat/lib/src/models/own_user.g.dart b/packages/stream_chat/lib/src/core/models/own_user.g.dart similarity index 61% rename from packages/stream_chat/lib/src/models/own_user.g.dart rename to packages/stream_chat/lib/src/core/models/own_user.g.dart index 760edc01..26e4786e 100644 --- a/packages/stream_chat/lib/src/models/own_user.g.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.g.dart @@ -38,29 +38,3 @@ OwnUser _$OwnUserFromJson(Map json) { banned: json['banned'] as bool? ?? false, ); } - -Map _$OwnUserToJson(OwnUser instance) { - final val = { - 'id': instance.id, - }; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('role', readonly(instance.role)); - writeNotNull('created_at', readonly(instance.createdAt)); - writeNotNull('updated_at', readonly(instance.updatedAt)); - writeNotNull('last_active', readonly(instance.lastActive)); - writeNotNull('online', readonly(instance.online)); - writeNotNull('banned', readonly(instance.banned)); - val['extra_data'] = instance.extraData; - writeNotNull('devices', readonly(instance.devices)); - writeNotNull('mutes', readonly(instance.mutes)); - writeNotNull('channel_mutes', readonly(instance.channelMutes)); - writeNotNull('total_unread_count', readonly(instance.totalUnreadCount)); - writeNotNull('unread_channels', readonly(instance.unreadChannels)); - return val; -} diff --git a/packages/stream_chat/lib/src/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart similarity index 84% rename from packages/stream_chat/lib/src/models/reaction.dart rename to packages/stream_chat/lib/src/core/models/reaction.dart index 85697204..57d0b48d 100644 --- a/packages/stream_chat/lib/src/models/reaction.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/serialization.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'reaction.g.dart'; @@ -21,7 +21,7 @@ class Reaction { /// Create a new instance from a json factory Reaction.fromJson(Map json) => - _$ReactionFromJson(Serialization.moveToExtraDataFromRoot( + _$ReactionFromJson(Serializer.moveToExtraDataFromRoot( json, topLevelFields, )); @@ -33,11 +33,11 @@ class Reaction { final String type; /// The date of the reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// The user that sent the reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final User? user; /// The score of the reaction (ie. number of reactions sent) @@ -45,7 +45,7 @@ class Reaction { final int score; /// The userId that sent the reaction - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String? userId; /// Reaction custom extraData @@ -66,7 +66,7 @@ class Reaction { ]; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$ReactionToJson(this), ); diff --git a/packages/stream_chat/lib/src/models/reaction.g.dart b/packages/stream_chat/lib/src/core/models/reaction.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/reaction.g.dart rename to packages/stream_chat/lib/src/core/models/reaction.g.dart diff --git a/packages/stream_chat/lib/src/models/read.dart b/packages/stream_chat/lib/src/core/models/read.dart similarity index 94% rename from packages/stream_chat/lib/src/models/read.dart rename to packages/stream_chat/lib/src/core/models/read.dart index cbd47dd1..55912762 100644 --- a/packages/stream_chat/lib/src/models/read.dart +++ b/packages/stream_chat/lib/src/core/models/read.dart @@ -1,5 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; part 'read.g.dart'; diff --git a/packages/stream_chat/lib/src/models/read.g.dart b/packages/stream_chat/lib/src/core/models/read.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/read.g.dart rename to packages/stream_chat/lib/src/core/models/read.g.dart diff --git a/packages/stream_chat/lib/src/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart similarity index 69% rename from packages/stream_chat/lib/src/models/user.dart rename to packages/stream_chat/lib/src/core/models/user.dart index 9335f8c8..c2960af9 100644 --- a/packages/stream_chat/lib/src/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -1,11 +1,12 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; part 'user.g.dart'; /// The class that defines the user model @JsonSerializable() -class User { +class User extends Equatable { /// Constructor used for json serialization User({ required this.id, @@ -21,11 +22,11 @@ class User { updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json - factory User.fromJson(Map json) => _$UserFromJson( - Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + factory User.fromJson(Map json) => + _$UserFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// Known top level fields. - /// Useful for [Serialization] methods. + /// Useful for [Serializer] methods. static const topLevelFields = [ 'id', 'role', @@ -41,36 +42,36 @@ class User { final String id; /// User role - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String? role; /// User role @JsonKey( includeIfNull: false, - toJson: Serialization.readOnly, + toJson: Serializer.readOnly, defaultValue: []) final List teams; /// Date of user creation - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime createdAt; /// Date of last user update - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime updatedAt; /// Date of last user connection - @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime? lastActive; /// True if user is online @JsonKey( - includeIfNull: false, toJson: Serialization.readOnly, defaultValue: false) + includeIfNull: false, toJson: Serializer.readOnly, defaultValue: false) final bool online; /// True if user is banned from the chat @JsonKey( - includeIfNull: false, toJson: Serialization.readOnly, defaultValue: false) + includeIfNull: false, toJson: Serializer.readOnly, defaultValue: false) final bool banned; /// Map of custom user extraData @@ -92,13 +93,17 @@ class User { return id; } + /// List of users to list of userIds + static List? toIds(List? users) => + users?.map((u) => u.id).toList(); + @override bool operator ==(Object other) => identical(this, other) || other is User && runtimeType == other.runtimeType && id == other.id; /// Serialize to json - Map toJson() => Serialization.moveFromExtraDataToRoot( + Map toJson() => Serializer.moveFromExtraDataToRoot( _$UserToJson(this), ); @@ -125,4 +130,17 @@ class User { banned: banned ?? this.banned, teams: teams ?? this.teams, ); + + @override + List get props => [ + id, + role, + teams, + createdAt, + updatedAt, + lastActive, + online, + banned, + extraData, + ]; } diff --git a/packages/stream_chat/lib/src/models/user.g.dart b/packages/stream_chat/lib/src/core/models/user.g.dart similarity index 100% rename from packages/stream_chat/lib/src/models/user.g.dart rename to packages/stream_chat/lib/src/core/models/user.g.dart diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart similarity index 95% rename from packages/stream_chat/lib/src/platform_detector/platform_detector.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart index 58d86db4..cf817cac 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart @@ -1,4 +1,4 @@ -import 'package:stream_chat/src/platform_detector/platform_detector_stub.dart' +import 'package:stream_chat/src/core/platform_detector/platform_detector_stub.dart' if (dart.library.html) 'platform_detector_web.dart' if (dart.library.io) 'platform_detector_io.dart'; diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_io.dart similarity index 82% rename from packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector_io.dart index c7b4a0b7..da707eed 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_io.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_io.dart @@ -1,5 +1,5 @@ import 'dart:io'; -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; /// Version running on native systems PlatformType get currentPlatform { diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_stub.dart similarity index 53% rename from packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector_stub.dart index b9e13c2e..9d1a7f66 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_stub.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_stub.dart @@ -1,4 +1,4 @@ -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; /// Stub implementation PlatformType get currentPlatform { diff --git a/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_web.dart similarity index 50% rename from packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart rename to packages/stream_chat/lib/src/core/platform_detector/platform_detector_web.dart index ba5d04fc..324b4145 100644 --- a/packages/stream_chat/lib/src/platform_detector/platform_detector_web.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector_web.dart @@ -1,4 +1,4 @@ -import 'package:stream_chat/src/platform_detector/platform_detector.dart'; +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; /// Version running on web PlatformType get currentPlatform => PlatformType.web; diff --git a/packages/stream_chat/lib/src/core/util/extension.dart b/packages/stream_chat/lib/src/core/util/extension.dart new file mode 100644 index 00000000..a28dc12b --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/extension.dart @@ -0,0 +1,33 @@ +import 'package:http_parser/http_parser.dart'; +import 'package:mime/mime.dart'; + +/// Useful extension functions for [Iterable] +extension IterableX on Iterable { + /// Removes all the null values + /// and converts `Iterable` into `Iterable` + Iterable get withNullifyer => whereType(); +} + +/// Useful extension functions for [Map] +extension MapX on Map { + /// Returns a new map with null keys or values removed + Map get nullProtected { + final nullProtected = {...this} + ..removeWhere((key, value) => key == null || value == null); + return nullProtected.cast(); + } +} + +/// Useful extension functions for [String] +extension StringX on String { + /// returns the mime type from the passed file name. + MediaType? get mimeType { + if (toLowerCase().endsWith('heic')) { + return MediaType.parse('image/heic'); + } else { + final mimeType = lookupMimeType(this); + if (mimeType == null) return null; + return MediaType.parse(mimeType); + } + } +} diff --git a/packages/stream_chat/lib/src/models/serialization.dart b/packages/stream_chat/lib/src/core/util/serializer.dart similarity index 85% rename from packages/stream_chat/lib/src/models/serialization.dart rename to packages/stream_chat/lib/src/core/util/serializer.dart index d584912e..2bf92e39 100644 --- a/packages/stream_chat/lib/src/models/serialization.dart +++ b/packages/stream_chat/lib/src/core/util/serializer.dart @@ -1,18 +1,12 @@ -import 'package:stream_chat/src/models/user.dart'; - /// Used to avoid to serialize properties to json // ignore: prefer_void_to_null Null readonly(_) => null; /// Helper class for serialization to and from json -class Serialization { +class Serializer { /// Used to avoid to serialize properties to json static const Function readOnly = readonly; - /// List of users to list of userIds - static List? userIds(List? users) => - users?.map((u) => u.id).toList(); - /// Takes unknown json keys and puts them in the `extra_data` key static Map moveToExtraDataFromRoot( Map json, diff --git a/packages/stream_chat/lib/src/core/util/utils.dart b/packages/stream_chat/lib/src/core/util/utils.dart new file mode 100644 index 00000000..c2204991 --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/utils.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:math' as math; + +// This alphabet uses `A-Za-z0-9_-` symbols. The genetic algorithm helped +// optimize the gzip compression for this alphabet. +const _alphabet = + 'ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW'; + +/// Generates a random String id +/// Adopted from: https://github.com/ai/nanoid/blob/main/non-secure/index.js +String randomId({int size = 21}) { + var id = ''; + for (var i = 0; i < size; i++) { + id += _alphabet[(math.Random().nextDouble() * 64).floor() | 0]; + } + return id; +} + +/// Creates a hash string from the passed [objects] +String generateHash(List objects) { + final payload = json.encode(objects); + final payloadBytes = utf8.encode(payload); + final payloadB64 = base64.encode(payloadBytes); + return payloadB64; +} diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 8f60482f..2470e403 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -1,14 +1,14 @@ -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/filter.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/src/extensions/iterable_extension.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; /// A simple client used for persisting chat data locally. abstract class ChatPersistenceClient { diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index fd93af2a..87529399 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -3,6 +3,9 @@ class EventType { /// Indicates any type of events static const String any = '*'; + /// + static const String healthCheck = 'health.check'; + /// Event sent when a user starts typing a message static const String typingStart = 'typing.start'; diff --git a/packages/stream_chat/lib/src/exceptions.dart b/packages/stream_chat/lib/src/exceptions.dart deleted file mode 100644 index 714bb386..00000000 --- a/packages/stream_chat/lib/src/exceptions.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:convert'; - -/// Exception related to api calls -class ApiError extends Error { - /// Creates a new ApiError instance using the response body and status code - ApiError(this.body, this.status) : jsonData = _decode(body) { - if (jsonData != null && jsonData!.containsKey('code')) { - _code = jsonData!['code']; - } - } - - /// Raw body of the response - final String? body; - - /// Json parsed body - final Map? jsonData; - - /// Http status code of the response - final int? status; - - /// Stream specific error code - int? get code => _code; - int? _code; - - static Map? _decode(String? body) { - try { - if (body == null) { - return null; - } - return json.decode(body); - } on FormatException { - return null; - } - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ApiError && - runtimeType == other.runtimeType && - body == other.body && - jsonData == other.jsonData && - status == other.status && - _code == other._code; - - @override - int get hashCode => - body.hashCode ^ jsonData.hashCode ^ status.hashCode ^ _code.hashCode; - - @override - String toString() => 'ApiError{body: $body, jsonData: $jsonData, ' - 'status: $status, code: $_code}'; -} diff --git a/packages/stream_chat/lib/src/extensions/iterable_extension.dart b/packages/stream_chat/lib/src/extensions/iterable_extension.dart deleted file mode 100644 index a7d30f76..00000000 --- a/packages/stream_chat/lib/src/extensions/iterable_extension.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// Useful extension functions for [Iterable] -extension IterableX on Iterable { - /// Removes all the null values - /// and converts `Iterable` into `Iterable` - Iterable get withNullifyer => [ - for (final item in this) - if (item != null) item - ]; -} diff --git a/packages/stream_chat/lib/src/extensions/map_extension.dart b/packages/stream_chat/lib/src/extensions/map_extension.dart deleted file mode 100644 index d99ab96a..00000000 --- a/packages/stream_chat/lib/src/extensions/map_extension.dart +++ /dev/null @@ -1,6 +0,0 @@ -/// Useful extension functions for [Map] -extension MapX on Map { - /// Returns a new map with null keys or values removed - Map get nullProtected => - Map.from(this)..removeWhere((key, value) => key == null || value == null); -} diff --git a/packages/stream_chat/lib/src/extensions/rate_limit.dart b/packages/stream_chat/lib/src/extensions/rate_limit.dart deleted file mode 100644 index bf54fe5c..00000000 --- a/packages/stream_chat/lib/src/extensions/rate_limit.dart +++ /dev/null @@ -1,335 +0,0 @@ -// ignore_for_file: lines_longer_than_80_chars - -import 'dart:async' show Timer; -import 'dart:math' as math; - -/// Useful rate limiter extensions for [Function] class. -extension RateLimit on Function { - /// Converts this into a [Debounce] function. - Debounce debounced( - Duration wait, { - bool leading = false, - bool trailing = true, - Duration? maxWait, - }) => - Debounce( - this, - wait, - leading: leading, - trailing: trailing, - maxWait: maxWait, - ); - - /// Converts this into a [Throttle] function. - Throttle throttled( - Duration wait, { - bool leading = true, - bool trailing = true, - }) => - Throttle( - this, - wait, - leading: leading, - trailing: trailing, - ); -} - -/// TopLevel lambda to create [Debounce] functions. -Debounce debounce( - Function func, - Duration wait, { - bool leading = false, - bool trailing = true, - Duration? maxWait, -}) => - Debounce( - func, - wait, - leading: leading, - trailing: trailing, - maxWait: maxWait, - ); - -/// TopLevel lambda to create [Throttle] functions. -Throttle throttle( - Function func, - Duration wait, { - bool leading = true, - bool trailing = true, -}) => - Throttle( - func, - wait, - leading: leading, - trailing: trailing, - ); - -/// Creates a debounced function that delays invoking `func` until after `wait` -/// milliseconds have elapsed since the last time the debounced function was -/// invoked. The debounced function comes with a [Debounce.cancel] method to cancel -/// delayed `func` invocations and a [Debounce.flush] method to immediately invoke them. -/// Provide `leading` and/or `trailing` to indicate whether `func` should be -/// invoked on the `leading` and/or `trailing` edge of the `wait` interval. -/// The `func` is invoked with the last arguments provided to the [call] -/// function. Subsequent calls to the debounced function return the result of -/// the last `func` invocation. -/// -/// **Note:** If `leading` and `trailing` options are `true`, `func` is -/// invoked on the trailing edge of the timeout only if the debounced function -/// is invoked more than once during the `wait` timeout. -/// -/// If `wait` is [Duration.zero] and `leading` is `false`, -/// `func` invocation is deferred until the next tick. -/// -/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) -/// for details over the differences between [Debounce] and [Throttle]. -/// -/// Some examples: -/// -/// Avoid calling costly network calls when user is typing something. -/// ```dart -/// void fetchData(String query) async { -/// final data = api.getData(query); -/// doSomethingWithTheData(data); -/// } -/// -/// final debouncedFetchData = Debounce( -/// fetchData, -/// const Duration(milliseconds: 350), -/// ); -/// -/// void onSearchQueryChanged(query) { -/// debouncedFetchData(query); -/// } -/// ``` -/// -/// Cancel the trailing debounced invocation. -/// ```dart -/// void dispose() { -/// debounced.cancel(); -/// } -/// ``` -/// -/// Check for pending invocations. -/// ```dart -/// final status = debounced.isPending ? "Pending..." : "Ready"; -/// ``` -class Debounce { - /// Creates a new instance of [Debounce]. - Debounce( - this._func, - Duration wait, { - bool leading = false, - bool trailing = true, - Duration? maxWait, - }) : _leading = leading, - _trailing = trailing, - _wait = wait.inMilliseconds, - _maxing = maxWait != null { - if (_maxing) { - _maxWait = math.max(maxWait!.inMilliseconds, _wait); - } - } - - final Function _func; - final bool _leading; - final bool _trailing; - final int _wait; - final bool _maxing; - - late int _maxWait; - List? _lastArgs; - Map? _lastNamedArgs; - Timer? _timer; - int? _lastCallTime; - Object? _result; - int? _lastInvokeTime = 0; - - Object? _invokeFunc(int? time) { - final args = _lastArgs; - final namedArgs = _lastNamedArgs; - _lastArgs = _lastNamedArgs = null; - _lastInvokeTime = time; - return _result = Function.apply(_func, args, namedArgs); - } - - Timer _startTimer(Function pendingFunc, int wait) => - Timer(Duration(milliseconds: wait), pendingFunc as void Function()); - - bool _shouldInvoke(int time) { - final timeSinceLastCall = time - (_lastCallTime ?? double.nan); - final timeSinceLastInvoke = time - _lastInvokeTime!; - - // Either this is the first call, activity has stopped and we're at the - // trailing edge, the system time has gone backwards and we're treating - // it as the trailing edge, or we've hit the `maxWait` limit. - return _lastCallTime == null || - (timeSinceLastCall >= _wait) || - (timeSinceLastCall < 0) || - (_maxing && timeSinceLastInvoke >= _maxWait); - } - - Object? _trailingEdge(int time) { - _timer = null; - - // Only invoke if we have `lastArgs` which means `func` has been - // debounced at least once. - if (_trailing && _lastArgs != null) { - return _invokeFunc(time); - } - _lastArgs = _lastNamedArgs = null; - return _result; - } - - int _remainingWait(int time) { - final timeSinceLastCall = time - _lastCallTime!; - final timeSinceLastInvoke = time - _lastInvokeTime!; - final timeWaiting = _wait - timeSinceLastCall; - - return _maxing - ? math.min(timeWaiting, _maxWait - timeSinceLastInvoke) - : timeWaiting; - } - - void _timerExpired() { - final time = DateTime.now().millisecondsSinceEpoch; - if (_shouldInvoke(time)) { - _trailingEdge(time); - } else { - // Restart the timer. - _timer = _startTimer(_timerExpired, _remainingWait(time)); - } - } - - Object? _leadingEdge(int? time) { - // Reset any `maxWait` timer. - _lastInvokeTime = time; - // Start the timer for the trailing edge. - _timer = _startTimer(_timerExpired, _wait); - // Invoke the leading edge. - return _leading ? _invokeFunc(time) : _result; - } - - /// Cancels all the remaining delayed functions. - void cancel() { - _timer?.cancel(); - _lastInvokeTime = 0; - _lastArgs = _lastNamedArgs = _lastCallTime = _timer = null; - } - - /// Immediately invokes all the remaining delayed functions. - Object? flush() { - final now = DateTime.now().millisecondsSinceEpoch; - return _timer == null ? _result : _trailingEdge(now); - } - - /// True if there are functions remaining to get invoked. - bool get isPending => _timer != null; - - /// Calls/invokes this class like a function. - /// Pass [args] and [namedArgs] to be used while invoking [_func]. - Object? call( - List args, { - Map? namedArgs, - }) { - final time = DateTime.now().millisecondsSinceEpoch; - final isInvoking = _shouldInvoke(time); - - _lastArgs = args; - _lastNamedArgs = namedArgs as Map?; - _lastCallTime = time; - - if (isInvoking) { - if (_timer == null) { - return _leadingEdge(_lastCallTime); - } - if (_maxing) { - // Handle invocations in a tight loop. - _timer = _startTimer(_timerExpired, _wait); - return _invokeFunc(_lastCallTime); - } - } - _timer ??= _startTimer(_timerExpired, _wait); - return _result; - } -} - -/// Creates a throttled function that only invokes `func` at most once per -/// every `wait` milliseconds. The throttled function comes with a [Throttle.cancel] -/// method to cancel delayed `func` invocations and a [Throttle.flush] method to -/// immediately invoke them. Provide `leading` and/or `trailing` to indicate -/// whether `func` should be invoked on the `leading` and/or `trailing` edge of the `wait` timeout. -/// The `func` is invoked with the last arguments provided to the -/// throttled function. Subsequent calls to the throttled function return the -/// result of the last `func` invocation. -/// -/// **Note:** If `leading` and `trailing` options are `true`, `func` is -/// invoked on the trailing edge of the timeout only if the throttled function -/// is invoked more than once during the `wait` timeout. -/// -/// If `wait` is [Duration.zero] and `leading` is `false`, `func` invocation is deferred -/// until the next tick. -/// -/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) -/// for details over the differences between [Throttle] and [Debounce]. -/// -/// Some examples: -/// -/// Avoid excessively rebuilding UI progress while uploading data to server. -/// ```dart -/// void updateUI(Data data) { -/// updateProgress(data); -/// } -/// -/// final throttledUpdateUI = Throttle( -/// updateUI, -/// const Duration(milliseconds: 350), -/// ); -/// -/// void onUploadProgressChanged(progress) { -/// throttledUpdateUI(progress); -/// } -/// ``` -/// -/// Cancel the trailing throttled invocation. -/// ```dart -/// void dispose() { -/// throttled.cancel(); -/// } -/// ``` -/// -/// Check for pending invocations. -/// ```dart -/// final status = throttled.isPending ? "Pending..." : "Ready"; -/// ``` -class Throttle { - /// Creates a new instance of [Throttle] - Throttle( - Function func, - Duration wait, { - bool leading = true, - bool trailing = true, - }) : _debounce = Debounce( - func, - wait, - leading: leading, - trailing: trailing, - maxWait: wait, - ); - - final Debounce _debounce; - - /// Cancels all the remaining delayed functions. - void cancel() => _debounce.cancel(); - - /// Immediately invokes all the remaining delayed functions. - Object? flush() => _debounce.flush(); - - /// True if there are functions remaining to get invoked. - bool get isPending => _debounce.isPending; - - /// Calls/invokes this class like a function. - /// Pass [args] and [namedArgs] to be used while invoking `func`. - Object? call(List args, {Map? namedArgs}) => - _debounce.call(args, namedArgs: namedArgs); -} diff --git a/packages/stream_chat/lib/src/extensions/string_extension.dart b/packages/stream_chat/lib/src/extensions/string_extension.dart deleted file mode 100644 index 440e2cb6..00000000 --- a/packages/stream_chat/lib/src/extensions/string_extension.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:http_parser/http_parser.dart' as http_parser; -import 'package:mime/mime.dart'; - -/// Useful extension functions for [String] -extension StringX on String { - /// Returns the mime type from the passed file name. - http_parser.MediaType? get mimeType { - if (toLowerCase().endsWith('heic')) { - return http_parser.MediaType.parse('image/heic'); - } else { - final mimeType = lookupMimeType(this); - if (mimeType == null) { - return null; - } - return http_parser.MediaType.parse(mimeType); - } - } -} diff --git a/packages/stream_chat/lib/src/location.dart b/packages/stream_chat/lib/src/location.dart new file mode 100644 index 00000000..86813a0a --- /dev/null +++ b/packages/stream_chat/lib/src/location.dart @@ -0,0 +1,29 @@ +/// +enum Location { + /// + usEast, + + /// + euWest, + + /// + mumbai, + + /// + sydney, + + /// + singapore, +} + +/// +extension LocationX on Location { + /// + String get name => { + Location.usEast: 'us-east', + Location.euWest: 'dublin', + Location.mumbai: 'mumbai', + Location.sydney: 'sydney', + Location.singapore: 'singapore', + }[this]!; +} diff --git a/packages/stream_chat/lib/src/api/connection_status.dart b/packages/stream_chat/lib/src/ws/connection_status.dart similarity index 100% rename from packages/stream_chat/lib/src/api/connection_status.dart rename to packages/stream_chat/lib/src/ws/connection_status.dart diff --git a/packages/stream_chat/lib/src/ws/timer_helper.dart b/packages/stream_chat/lib/src/ws/timer_helper.dart new file mode 100644 index 00000000..0974ee80 --- /dev/null +++ b/packages/stream_chat/lib/src/ws/timer_helper.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'package:uuid/uuid.dart'; + +/// +class TimerHelper { + final _uuid = const Uuid(); + late final _timers = {}; + + /// + String setTimer( + Duration duration, + void Function() callback, { + bool immediate = false, + }) { + final id = _uuid.v1(); + final timer = Timer(duration, callback); + if (immediate) callback(); + _timers[id] = timer; + return id; + } + + /// + String setPeriodicTimer( + Duration duration, + void Function(Timer) callback, { + bool immediate = false, + }) { + final id = _uuid.v1(); + final timer = Timer.periodic(duration, callback); + if (immediate) callback.call(timer); + _timers[id] = timer; + return id; + } + + /// + void cancelTimer(String id) { + final timer = _timers.remove(id); + return timer?.cancel(); + } + + /// + void cancelAllTimers() { + for (final t in _timers.values) { + t.cancel(); + } + _timers.clear(); + } + + /// + bool get hasTimers => _timers.isNotEmpty; +} diff --git a/packages/stream_chat/lib/src/ws/websocket.dart b/packages/stream_chat/lib/src/ws/websocket.dart new file mode 100644 index 00000000..f9d8a210 --- /dev/null +++ b/packages/stream_chat/lib/src/ws/websocket.dart @@ -0,0 +1,425 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/ws/connection_status.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/ws/timer_helper.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +/// Typedef which exposes an [Event] as the only parameter. +typedef EventHandler = void Function(Event); + +/// Typedef used for connecting to a websocket. Method returns a +/// [WebSocketChannel] and accepts a connection [url] and an optional +/// [Iterable] of `protocols`. +typedef WebSocketChannelProvider = WebSocketChannel Function( + Uri uri, { + Iterable? protocols, +}); + +/// A WebSocket connection that reconnects upon failure. +class WebSocket with TimerHelper { + /// Creates a new websocket + /// To connect the WS call [connect] + WebSocket({ + required this.apiKey, + required this.baseUrl, + required this.tokenManager, + this.handler, + Logger? logger, + this.webSocketChannelProvider, + this.reconnectionMonitorInterval = 10, + this.healthCheckInterval = 20, + this.reconnectionMonitorTimeout = 40, + }) : _logger = logger; + + /// + final String apiKey; + + /// WS base url + final String baseUrl; + + /// + final TokenManager tokenManager; + + /// Functions that will be called every time a new event is received from the + /// connection + final EventHandler? handler; + + final Logger? _logger; + + /// Connection function + /// Used only for testing purpose + @visibleForTesting + final WebSocketChannelProvider? webSocketChannelProvider; + + /// Interval of the reconnection monitor timer + /// This checks that it received a new event in the last + /// [reconnectionMonitorTimeout] seconds, otherwise it considers the + /// connection unhealthy and reconnects the WS + final int reconnectionMonitorInterval; + + /// Interval of the health event sending timer + /// This sends a health event every [healthCheckInterval] seconds in order to + /// make the server aware that the client is still listening + final int healthCheckInterval; + + /// The timeout that uses the reconnection monitor timer to consider the + /// connection unhealthy + final int reconnectionMonitorTimeout; + + User? _user; + String? _connectionId; + DateTime? _lastEventAt; + WebSocketChannel? _webSocketChannel; + StreamSubscription? _webSocketChannelSubscription; + + /// + Completer? connectionCompleter; + + /// + String? get connectionId => _connectionId; + + final _connectionStatusController = + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set _connectionStatus(ConnectionStatus status) => + _connectionStatusController.add(status); + + /// The current connection status value + ConnectionStatus get connectionStatus => _connectionStatusController.value; + + /// This notifies of connection status changes + Stream get connectionStatusStream => + _connectionStatusController.stream.distinct(); + + void _initWebSocketChannel(Uri uri) { + _logger?.info('Initiating connection with $baseUrl'); + if (_webSocketChannel != null) { + _closeWebSocketChannel(); + } + _webSocketChannel = + webSocketChannelProvider?.call(uri) ?? WebSocketChannel.connect(uri); + _subscribeToWebSocketChannel(); + } + + void _closeWebSocketChannel() { + _logger?.info('Closing connection with $baseUrl'); + if (_webSocketChannel != null) { + _unsubscribeFromWebSocketChannel(); + _webSocketChannel?.sink.close(status.goingAway); + _webSocketChannel = null; + } + } + + void _subscribeToWebSocketChannel() { + _logger?.info('Started listening to $baseUrl'); + if (_webSocketChannelSubscription != null) { + _unsubscribeFromWebSocketChannel(); + } + _webSocketChannelSubscription = _webSocketChannel?.stream.listen( + _onDataReceived, + onError: _onConnectionError, + onDone: _onConnectionClosed, + ); + } + + void _unsubscribeFromWebSocketChannel() { + _logger?.info('Stopped listening to $baseUrl'); + if (_webSocketChannelSubscription != null) { + _webSocketChannelSubscription?.cancel(); + _webSocketChannelSubscription = null; + } + } + + Future _buildUri({bool refreshToken = false}) async { + final user = _user!; + final token = await tokenManager.loadToken(refresh: refreshToken); + final params = { + 'user_id': user.id, + 'user_details': user, + 'user_token': token.rawValue, + 'server_determines_connection_id': true, + }; + final qs = { + 'json': jsonEncode(params), + 'api_key': apiKey, + 'authorization': token.rawValue, + 'stream-auth-type': token.authType.raw, + }; + final scheme = baseUrl.startsWith('https') ? 'wss' : 'ws'; + final host = baseUrl.replaceAll(RegExp(r'(^\w+:|^)\/\/'), ''); + return Uri( + scheme: scheme, + host: host, + pathSegments: ['connect'], + queryParameters: qs, + ); + } + + bool _connectRequestInProgress = false; + + /// Connect the WS using the parameters passed in the constructor + Future connect(User user) async { + if (_connectRequestInProgress) { + throw const StreamWebSocketError(''' + You've called connect twice, + can only attempt 1 connection at the time, + '''); + } + _connectRequestInProgress = true; + _manuallyClosed = false; + + _user = user; + _connectionStatus = ConnectionStatus.connecting; + connectionCompleter = Completer(); + + final uri = await _buildUri(); + _initWebSocketChannel(uri); + + return connectionCompleter!.future; + } + + int _reconnectAttempt = 0; + bool _reconnectRequestInProgress = false; + + void _reconnect({bool refreshToken = false}) async { + _logger?.info('Retrying connection : $_reconnectAttempt'); + if (_reconnectRequestInProgress) return; + _reconnectRequestInProgress = true; + + _stopMonitoringEvents(); + // Closing any previously opened web-socket + _closeWebSocketChannel(); + + _reconnectAttempt += 1; + _connectionStatus = ConnectionStatus.connecting; + + final delay = _getReconnectInterval(_reconnectAttempt); + setTimer( + Duration(milliseconds: delay), + () async { + final uri = await _buildUri(refreshToken: refreshToken); + _initWebSocketChannel(uri); + }, + ); + } + + // returns the reconnect interval based on `reconnectAttempt` in milliseconds + int _getReconnectInterval(int reconnectAttempt) { + // try to reconnect in 0.25-25 seconds + // (random to spread out the load from failures) + final max = math.min(500 + reconnectAttempt * 2000, 25000); + final min = math.min( + math.max(250, (reconnectAttempt - 1) * 2000), + 25000, + ); + return (math.Random().nextDouble() * (max - min) + min).floor(); + } + + void _startMonitoringEvents() { + _logger?.info('Starting monitoring events'); + // cancel all previous timers + cancelAllTimers(); + + _startHealthCheck(); + _startReconnectionMonitor(); + } + + void _stopMonitoringEvents() { + _logger?.info('Stopped monitoring events'); + // reset lastEvent + _lastEventAt = null; + + cancelAllTimers(); + } + + void _handleConnectedEvent(Event event) { + // updating connectionId and status + _connectionId = event.connectionId; + _connectionStatus = ConnectionStatus.connected; + + _logger?.info('Connection successful: $_connectionId'); + + // notify user that connection is completed + final completer = connectionCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(event); + } + + // start monitoring health-check events + _startMonitoringEvents(); + } + + void _handleHealthCheckEvent(Event event) { + _logger?.info('HealthCheck received : ${event.connectionId}'); + + _connectionId = event.connectionId; + _connectionStatus = ConnectionStatus.connected; + } + + void _handleStreamError(Map errorResponse) { + // resetting connect, reconnect request flag + _resetRequestFlags(); + + final error = StreamWebSocketError.fromStreamError(errorResponse); + final isTokenExpired = error.errorCode == ChatErrorCode.tokenExpired; + if (isTokenExpired && !tokenManager.isStatic) { + _logger?.warning('Connection failed, token expired'); + return _reconnect(refreshToken: true); + } + + _logger?.severe('Connection failed', error); + + final completer = connectionCompleter; + // complete with error if not yet completed + if (completer != null && !completer.isCompleted) { + // complete the connection with error + completer.completeError(error); + // disconnect the web-socket connection + return disconnect(); + } + + return _reconnect(); + } + + void _onDataReceived(dynamic data) { + final jsonData = json.decode(data) as Map; + final error = jsonData['error'] as Map?; + if (error != null) return _handleStreamError(error); + + // resetting connect, reconnect request flag + _resetRequestFlags(resetAttempts: true); + + Event? event; + try { + event = Event.fromJson(jsonData); + } catch (_) {} + + if (event == null) return; + + _lastEventAt = DateTime.now(); + _logger?.info('Event received: ${event.type}'); + + if (event.type == EventType.healthCheck) { + if (event.me != null) { + _handleConnectedEvent(event); + } else { + _handleHealthCheckEvent(event); + } + } + + return handler?.call(event); + } + + void _onConnectionError(error, [stacktrace]) { + _logger?.warning('Error occurred', error, stacktrace); + + StreamWebSocketError wsError; + if (error is WebSocketChannelException) { + wsError = StreamWebSocketError.fromWebSocketChannelError(error); + } else { + wsError = StreamWebSocketError(error.toString()); + } + + final completer = connectionCompleter; + // complete with error if not yet completed + if (completer != null && !completer.isCompleted) { + // complete the connection with error + completer.completeError(wsError); + } + + // resetting connect, reconnect request flag + _resetRequestFlags(); + + _reconnect(); + } + + bool _manuallyClosed = false; + + void _onConnectionClosed() { + _logger?.warning('Connection closed : $connectionId'); + + // resetting connect, reconnect request flag + _resetRequestFlags(); + + // resetting connection + _connectionId = null; + + // check if we manually closed the connection + if (_manuallyClosed) return; + _reconnect(); + } + + bool get _needsToReconnect { + final lastEventAt = _lastEventAt; + // means not yet connected or disconnected + if (lastEventAt == null) return false; + + // means we missed a health check + final now = DateTime.now(); + return now.difference(lastEventAt).inSeconds > reconnectionMonitorTimeout; + } + + void _resetRequestFlags({bool resetAttempts = false}) { + _connectRequestInProgress = false; + _reconnectRequestInProgress = false; + if (resetAttempts) _reconnectAttempt = 0; + } + + void _startReconnectionMonitor() { + _logger?.info('Starting reconnection monitor'); + setPeriodicTimer( + Duration(seconds: reconnectionMonitorInterval), + (_) { + final needsToReconnect = _needsToReconnect; + _logger?.info('Needs to reconnect : $needsToReconnect'); + if (needsToReconnect) _reconnect(); + }, + immediate: true, + ); + } + + void _startHealthCheck() { + _logger?.info('Starting health check monitor'); + setPeriodicTimer( + Duration(seconds: healthCheckInterval), + (_) { + _logger?.info('Sending Event: ${EventType.healthCheck}'); + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + ); + _webSocketChannel?.sink.add(jsonEncode(event)); + }, + immediate: true, + ); + } + + /// Disconnects the WS and releases eventual resources + void disconnect() { + if (connectionStatus == ConnectionStatus.disconnected) return; + _connectionStatus = ConnectionStatus.disconnected; + + _logger?.info('Disconnecting web-socket connection'); + + // resetting user + _user = null; + connectionCompleter = null; + + _stopMonitoringEvents(); + + _manuallyClosed = true; + _closeWebSocketChannel(); + } +} diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 2ac56b10..571d78f5 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -6,33 +6,36 @@ export 'package:dio/src/multipart_file.dart'; export 'package:dio/src/options.dart'; export 'package:dio/src/options.dart' show ProgressCallback; export 'package:logging/logging.dart' show Logger, Level; +export 'package:rate_limiter/rate_limiter.dart'; -export './src/api/channel.dart'; -export './src/api/connection_status.dart'; -export './src/api/requests.dart'; -export './src/api/requests.dart'; -export './src/api/responses.dart'; -export './src/attachment_file_uploader.dart' show AttachmentFileUploader; -export './src/client.dart'; +export './src/core/api/attachment_file_uploader.dart' + show AttachmentFileUploader; +export './src/core/api/requests.dart'; +export './src/core/api/requests.dart'; +export './src/core/api/responses.dart'; +export './src/core/api/stream_chat_api.dart' show PushProvider; +export './src/core/error/error.dart'; +export './src/core/models/action.dart'; +export './src/core/models/attachment.dart'; +export './src/core/models/attachment_file.dart'; +export './src/core/models/channel_config.dart'; +export './src/core/models/channel_model.dart'; +export './src/core/models/channel_state.dart'; +export './src/core/models/command.dart'; +export './src/core/models/device.dart'; +export './src/core/models/event.dart'; +export './src/core/models/filter.dart' show Filter; +export './src/core/models/member.dart'; +export './src/core/models/message.dart'; +export './src/core/models/mute.dart'; +export './src/core/models/own_user.dart'; +export './src/core/models/reaction.dart'; +export './src/core/models/read.dart'; +export './src/core/models/user.dart'; +export './src/core/util/extension.dart'; export './src/db/chat_persistence_client.dart'; export './src/event_type.dart'; -export './src/exceptions.dart'; -export './src/extensions/rate_limit.dart'; -export './src/extensions/string_extension.dart'; -export './src/models/action.dart'; -export './src/models/attachment.dart'; -export './src/models/attachment_file.dart'; -export './src/models/channel_config.dart'; -export './src/models/channel_model.dart'; -export './src/models/channel_state.dart'; -export './src/models/command.dart'; -export './src/models/device.dart'; -export './src/models/event.dart'; -export './src/models/filter.dart' show Filter; -export './src/models/member.dart'; -export './src/models/message.dart'; -export './src/models/mute.dart'; -export './src/models/own_user.dart'; -export './src/models/reaction.dart'; -export './src/models/read.dart'; -export './src/models/user.dart'; +export './src/location.dart'; +export './src/ws/connection_status.dart'; +export 'src/client/channel.dart'; +export 'src/client/client.dart'; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index 6c68818a..cb3d587f 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -1,6 +1,6 @@ -import 'package:stream_chat/src/client.dart'; +import 'package:stream_chat/src/client/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header // ignore: constant_identifier_names -const PACKAGE_VERSION = '2.0.0-nullsafety.2'; +const PACKAGE_VERSION = '2.0.0-nullsafety.7'; diff --git a/packages/stream_chat/peanut.yaml b/packages/stream_chat/peanut.yaml deleted file mode 100644 index 97d20f52..00000000 --- a/packages/stream_chat/peanut.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Configuration for https://pub.dev/packages/peanut -directories: - - example/web diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index d473beea..4901adf0 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 2.0.0-nullsafety.2 +version: 2.0.0-nullsafety.7 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -15,10 +15,12 @@ dependencies: equatable: ^2.0.0 freezed_annotation: ^0.14.0 http_parser: ^4.0.0 + jose: ^0.3.2 json_annotation: ^4.0.1 logging: ^1.0.1 meta: ^1.3.0 mime: ^1.0.0 + rate_limiter: ^0.1.1 rxdart: ^0.27.0 uuid: ^3.0.4 web_socket_channel: ^2.0.0 @@ -28,4 +30,4 @@ dev_dependencies: freezed: ^0.14.1+3 json_serializable: ^4.1.0 mocktail: ^0.1.1 - test: ^1.16.8 + test: ^1.17.7 \ No newline at end of file diff --git a/packages/stream_chat/test/assets/example.pdf b/packages/stream_chat/test/assets/example.pdf new file mode 100644 index 00000000..d736dedc --- /dev/null +++ b/packages/stream_chat/test/assets/example.pdf @@ -0,0 +1,57 @@ +%PDF-1.7 +%ĩíŽû +3 0 obj +<< /Length 4 0 R >> +stream +/DeviceRGB cs /DeviceRGB CS +0 0 0.972549 SC +21.68 194 136.64 26 re +10 10 m 20 20 l S +BT +/F0 24 Tf +25.68 200 Td +(Hello World!) Tj +ET +endstream +endobj +4 0 obj +132 +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >> +endobj +6 0 obj +<< /Type /Page + /Parent 2 0 R + /Resources << /Font << /F0 5 0 R >> >> + /MediaBox [ 0 0 180 240 ] + /Contents 3 0 R +>> +endobj +2 0 obj +<< /Type /Pages + /Count 1 + /Kids [ 6 0 R ] +>> +endobj +1 0 obj +<< /Type /Catalog + /Pages 2 0 R +>> +endobj +xref +0 7 +0000000000 65535 f +0000000522 00000 n +0000000457 00000 n +0000000015 00000 n +0000000199 00000 n +0000000218 00000 n +0000000317 00000 n +trailer +<< /Size 7 + /Root 1 0 R +>> +startxref +574 +%%EOF diff --git a/packages/stream_chat/test/assets/test_image.jpeg b/packages/stream_chat/test/assets/test_image.jpeg new file mode 100644 index 00000000..aeaccec2 Binary files /dev/null and b/packages/stream_chat/test/assets/test_image.jpeg differ diff --git a/packages/stream_chat/test/fixtures/action.json b/packages/stream_chat/test/fixtures/action.json new file mode 100644 index 00000000..59da5684 --- /dev/null +++ b/packages/stream_chat/test/fixtures/action.json @@ -0,0 +1,7 @@ +{ + "name": "name", + "style": "style", + "text": "text", + "type": "type", + "value": "value" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/attachment.json b/packages/stream_chat/test/fixtures/attachment.json new file mode 100644 index 00000000..faf38b90 --- /dev/null +++ b/packages/stream_chat/test/fixtures/attachment.json @@ -0,0 +1,29 @@ +{ + "type": "giphy", + "title": "awesome", + "title_link": "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti", + "thumb_url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif", + "actions": [ + { + "name": "image_action", + "text": "Send", + "style": "primary", + "type": "button", + "value": "send" + }, + { + "name": "image_action", + "text": "Shuffle", + "style": "default", + "type": "button", + "value": "shuffle" + }, + { + "name": "image_action", + "text": "Cancel", + "style": "default", + "type": "button", + "value": "cancel" + } + ] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/channel.json b/packages/stream_chat/test/fixtures/channel.json new file mode 100644 index 00000000..2184cd6e --- /dev/null +++ b/packages/stream_chat/test/fixtures/channel.json @@ -0,0 +1,7 @@ +{ + "id": "test", + "type": "livestream", + "cid": "livestream:test", + "cats": true, + "fruit": ["bananas", "apples"] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/channel_state.json b/packages/stream_chat/test/fixtures/channel_state.json new file mode 100644 index 00000000..1e6af605 --- /dev/null +++ b/packages/stream_chat/test/fixtures/channel_state.json @@ -0,0 +1,832 @@ + +{ + "channel": { + "id": "dev", + "type": "team", + "cid": "team:dev", + "last_message_at": "2020-01-30T13:43:41.062362Z", + "created_at": "2019-04-03T18:43:33.213373Z", + "updated_at": "2019-04-03T18:43:33.213374Z", + "team": "test", + "created_by": { + "id": "guido", + "role": "user", + "created_at": "2019-04-03T18:43:33.201036Z", + "updated_at": "2019-04-03T18:43:33.204713Z", + "banned": false, + "online": false, + "name": "Guido" + }, + "frozen": true, + "config": { + "created_at": "2019-11-07T22:29:26.776526Z", + "updated_at": "2019-11-07T22:29:48.286746Z", + "name": "team", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "#dev", + "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", + "example": 1 + }, + "messages": [ + { + "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", + "text": "fasdfa", + "type": "regular", + "status": "SENT", + "silent": false, + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:02.843948Z", + "updated_at": "2020-01-29T03:23:02.843949Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", + "text": "test message", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:07.981091Z", + "updated_at": "2020-01-29T03:23:07.981091Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", + "text": "test message", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:11.568022Z", + "updated_at": "2020-01-29T03:23:11.568022Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", + "text": "asdfadf", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:32:57.403566Z", + "updated_at": "2020-01-29T03:32:57.403566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", + "text": "test", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:33:35.294802Z", + "updated_at": "2020-01-29T03:33:35.294802Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", + "text": "hi", + "type": "regular", + "user": { + "id": "withered-cell-0", + "role": "user", + "created_at": "2020-01-29T03:34:01.698106Z", + "updated_at": "2020-01-29T03:34:01.708808Z", + "last_active": "2020-01-29T03:34:01.70353Z", + "banned": false, + "online": false, + "name": "Withered cell", + "image": "https://getstream.io/random_svg/?name=Withered+cell" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:34:27.393296Z", + "updated_at": "2020-01-29T03:34:27.393296Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", + "text": "fantastic", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:34:37.638376Z", + "updated_at": "2020-01-29T03:34:37.638376Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", + "text": "nice to meet you", + "type": "regular", + "user": { + "id": "withered-cell-0", + "role": "user", + "created_at": "2020-01-29T03:34:01.698106Z", + "updated_at": "2020-01-29T03:34:01.708808Z", + "last_active": "2020-01-29T03:34:01.70353Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Withered+cell", + "name": "Withered cell" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:04.301566Z", + "updated_at": "2020-01-29T03:35:04.301566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", + "text": "hey", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:24.939084Z", + "updated_at": "2020-01-29T03:35:24.939085Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", + "text": "hello, everyone", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "name": "Dry meadow", + "image": "https://getstream.io/random_svg/?name=Dry+meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:33.101566Z", + "updated_at": "2020-01-29T03:35:33.101566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", + "text": "who is there?", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "name": "Dry meadow", + "image": "https://getstream.io/random_svg/?name=Dry+meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:45.458685Z", + "updated_at": "2020-01-29T03:35:45.458685Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", + "text": "í•˜ė´", + "type": "regular", + "user": { + "id": "icy-recipe-7", + "role": "user", + "created_at": "2020-01-21T11:36:22.284503Z", + "updated_at": "2020-01-29T07:01:59.69882Z", + "last_active": "2020-01-29T07:01:59.693378Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Icy+recipe", + "name": "Icy recipe" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T07:02:11.535395Z", + "updated_at": "2020-01-29T07:02:11.535395Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", + "text": "what are you doing?", + "type": "regular", + "user": { + "id": "icy-recipe-7", + "role": "user", + "created_at": "2020-01-21T11:36:22.284503Z", + "updated_at": "2020-01-29T07:01:59.69882Z", + "last_active": "2020-01-29T07:01:59.693378Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Icy+recipe", + "name": "Icy recipe" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T07:02:22.485136Z", + "updated_at": "2020-01-29T07:02:22.485136Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", + "text": "👍", + "type": "regular", + "user": { + "id": "throbbing-boat-5", + "role": "user", + "created_at": "2019-07-30T06:29:53.060413Z", + "updated_at": "2020-01-29T14:11:27.80176Z", + "last_active": "2020-01-29T14:11:27.7963Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Throbbing+boat", + "name": "Throbbing boat" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T14:12:04.688552Z", + "updated_at": "2020-01-29T14:12:04.688552Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", + "text": "sdasas", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:36.011315Z", + "updated_at": "2020-01-29T15:29:36.011316Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", + "text": "cjshsa", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:41.677819Z", + "updated_at": "2020-01-29T15:29:41.677819Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", + "text": "nhisagdhsadz", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:43.354177Z", + "updated_at": "2020-01-29T15:29:43.354177Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", + "text": "hvadhsahzd", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:44.754713Z", + "updated_at": "2020-01-29T15:29:44.754713Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", + "text": "hello", + "type": "regular", + "user": { + "id": "divine-glade-9", + "role": "user", + "created_at": "2020-01-29T17:02:18.312524Z", + "updated_at": "2020-01-29T17:02:18.320187Z", + "last_active": "2020-01-29T17:02:18.315074Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Divine+glade", + "name": "Divine glade" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T17:02:36.933852Z", + "updated_at": "2020-01-29T17:02:36.933852Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", + "text": "hello", + "type": "regular", + "user": { + "id": "red-firefly-9", + "role": "user", + "created_at": "2019-08-02T18:56:39.366516Z", + "updated_at": "2020-01-29T22:13:50.491769Z", + "last_active": "2020-01-29T22:13:50.450215Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Red+firefly", + "name": "Red firefly" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T22:14:08.54062Z", + "updated_at": "2020-01-29T22:14:08.54062Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", + "text": "hello", + "type": "regular", + "user": { + "id": "bitter-glade-2", + "role": "user", + "created_at": "2020-01-30T13:08:56.190678Z", + "updated_at": "2020-01-30T13:08:56.200333Z", + "last_active": "2020-01-30T13:08:56.193882Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Bitter+glade", + "name": "Bitter glade" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:11:37.191293Z", + "updated_at": "2020-01-30T13:11:37.191293Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", + "text": "http://jaeger.ui.gtstrm.com/", + "type": "regular", + "user": { + "id": "morning-sea-1", + "role": "user", + "created_at": "2019-07-22T09:19:07.505207Z", + "updated_at": "2020-01-30T13:33:05.831856Z", + "last_active": "2020-01-30T13:33:05.825369Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Morning+sea", + "name": "Morning sea" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:33:16.853116Z", + "updated_at": "2020-01-30T13:33:16.853116Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", + "text": "hi", + "type": "regular", + "user": { + "id": "ancient-salad-0", + "role": "user", + "created_at": "2020-01-30T13:34:29.286813Z", + "updated_at": "2020-01-30T13:34:29.296196Z", + "last_active": "2020-01-30T13:34:29.289964Z", + "banned": false, + "online": true, + "image": "https://getstream.io/random_svg/?name=Ancient+salad", + "name": "Ancient salad" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:36:52.749731Z", + "updated_at": "2020-01-30T13:36:52.749732Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", + "text": "hi", + "type": "regular", + "user": { + "id": "ancient-salad-0", + "role": "user", + "created_at": "2020-01-30T13:34:29.286813Z", + "updated_at": "2020-01-30T13:34:29.296196Z", + "last_active": "2020-01-30T13:34:29.289964Z", + "banned": false, + "online": true, + "image": "https://getstream.io/random_svg/?name=Ancient+salad", + "name": "Ancient salad" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:37:41.631056Z", + "updated_at": "2020-01-30T13:37:41.631056Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", + "text": "😃", + "type": "regular", + "user": { + "id": "proud-sea-7", + "role": "user", + "created_at": "2020-01-30T13:43:03.903006Z", + "updated_at": "2020-01-30T13:43:03.912307Z", + "last_active": "2020-01-30T13:43:03.906236Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Proud+sea", + "name": "Proud sea" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:43:41.062362Z", + "updated_at": "2020-01-30T13:43:41.062362Z", + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + } + ], + "watcher_count": 5, + "members": [] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/channel_state_to_json.json b/packages/stream_chat/test/fixtures/channel_state_to_json.json new file mode 100644 index 00000000..4b8a0369 --- /dev/null +++ b/packages/stream_chat/test/fixtures/channel_state_to_json.json @@ -0,0 +1,418 @@ + +{ + "channel": { + "id": "dev", + "type": "team", + "frozen": true, + "name": "#dev", + "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", + "example": 1 + }, + "watchers": [], + "read": [], + "messages": [ + { + "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", + "text": "fasdfa", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", + "text": "test message", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", + "text": "test message", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", + "text": "asdfadf", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", + "text": "test", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", + "text": "fantastic", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", + "text": "nice to meet you", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", + "text": "hey", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", + "text": "hello, everyone", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", + "text": "who is there?", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", + "text": "í•˜ė´", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", + "text": "what are you doing?", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", + "text": "👍", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", + "text": "sdasas", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", + "text": "cjshsa", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", + "text": "nhisagdhsadz", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", + "text": "hvadhsahzd", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", + "text": "http://jaeger.ui.gtstrm.com/", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + }, + { + "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", + "text": "😃", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null + } + ], + "pinned_messages": [], + "members": [], + "watcher_count": 5 +} diff --git a/packages/stream_chat/test/fixtures/command.json b/packages/stream_chat/test/fixtures/command.json new file mode 100644 index 00000000..38a169a5 --- /dev/null +++ b/packages/stream_chat/test/fixtures/command.json @@ -0,0 +1,5 @@ +{ + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/device.json b/packages/stream_chat/test/fixtures/device.json new file mode 100644 index 00000000..4555dc1e --- /dev/null +++ b/packages/stream_chat/test/fixtures/device.json @@ -0,0 +1,4 @@ +{ + "id": "device-id", + "push_provider": "push-provider" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/event.json b/packages/stream_chat/test/fixtures/event.json new file mode 100644 index 00000000..2568af02 --- /dev/null +++ b/packages/stream_chat/test/fixtures/event.json @@ -0,0 +1,29 @@ +{ + "type": "type", + "cid": "cid", + "connection_id": "connectionId", + "created_at": "2019-04-03T18:43:33.213374Z", + "me": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "parent_id": null, + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + } +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/event_channel.json b/packages/stream_chat/test/fixtures/event_channel.json new file mode 100644 index 00000000..6078c545 --- /dev/null +++ b/packages/stream_chat/test/fixtures/event_channel.json @@ -0,0 +1,99 @@ +{ + "id": "!members-v9ktpgmYysZA-MjgC-GMoeEawFHSelkOdTu6JGxFZWU", + "type": "messaging", + "cid": "messaging:!members-v9ktpgmYysZA-MjgC-GMoeEawFHSelkOdTu6JGxFZWU", + "last_message_at": "2020-12-02T04:22:16.755334Z", + "created_at": "2020-10-01T08:49:32.90162Z", + "updated_at": "2021-06-17T08:18:48.188996Z", + "created_by": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-17T08:12:05.062115Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "frozen": false, + "disabled": false, + "members": [ + { + "user_id": "super-band-9", + "user": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-17T08:12:05.062115Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "role": "owner", + "created_at": "2020-10-01T08:49:32.905052Z", + "updated_at": "2020-10-01T08:49:32.905052Z", + "banned": false, + "shadow_banned": false + }, + { + "user_id": "cc48de8e-b2db-48e7-bba5-c03cefd61430", + "user": { + "id": "cc48de8e-b2db-48e7-bba5-c03cefd61430", + "role": "user", + "created_at": "2020-07-22T14:59:38.026809Z", + "updated_at": "2020-07-22T14:59:38.31338Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg", + "name": "Ana De Armas" + }, + "role": "member", + "created_at": "2020-10-01T08:49:32.905053Z", + "updated_at": "2020-10-01T08:49:32.905053Z", + "banned": false, + "shadow_banned": false + } + ], + "member_count": 2, + "config": { + "created_at": "2020-04-15T14:57:17.00966Z", + "updated_at": "2021-05-25T14:25:30.405621Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist": "profanity_en_2020_v1", + "blocklist_behavior": "block", + "automod_thresholds": {}, + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "test" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/member.json b/packages/stream_chat/test/fixtures/member.json new file mode 100644 index 00000000..a33acf13 --- /dev/null +++ b/packages/stream_chat/test/fixtures/member.json @@ -0,0 +1,15 @@ +{ + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "name": "Robin Papa", + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.95443Z", + "updated_at": "2020-01-28T22:17:30.95443Z" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/message.json b/packages/stream_chat/test/fixtures/message.json new file mode 100644 index 00000000..d469639b --- /dev/null +++ b/packages/stream_chat/test/fixtures/message.json @@ -0,0 +1,65 @@ +{ + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "type": "regular", + "silent": false, + "status": "SENT", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/message_to_json.json b/packages/stream_chat/test/fixtures/message_to_json.json new file mode 100644 index 00000000..b3b1dd6b --- /dev/null +++ b/packages/stream_chat/test/fixtures/message_to_json.json @@ -0,0 +1,29 @@ +{ + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "silent": false, + "attachments": [ + { + "type": "video", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "title": "The Lion King Disney GIF - Find & Share on GIPHY", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover & share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "author_name": "GIPHY", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "actions": [] + } + ], + "mentioned_users": [], + "parent_id": "parentId", + "quoted_message": null, + "quoted_message_id": null, + "pinned": false, + "pinned_at": null, + "pin_expires": null, + "pinned_by": null, + "show_in_channel": true, + "hey": "test" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/mute.json b/packages/stream_chat/test/fixtures/mute.json new file mode 100644 index 00000000..cc6c3a8c --- /dev/null +++ b/packages/stream_chat/test/fixtures/mute.json @@ -0,0 +1,74 @@ +{ + "user": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:42:29.466165498Z", + "banned": false, + "online": true, + "username": "Rioland", + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0 + }, + "channel": { + "id": "!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "type": "messaging", + "cid": "messaging:!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "last_message_at": "2020-12-02T06:56:18.003432Z", + "created_at": "2020-11-30T10:25:32.494601Z", + "updated_at": "2020-11-30T10:25:32.494601Z", + "created_by": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:42:29.466165498Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "frozen": false, + "disabled": false, + "member_count": 2, + "config": { + "created_at": "2020-04-15T14:57:17.00966Z", + "updated_at": "2021-05-25T14:25:30.405621Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist": "profanity_en_2020_v1", + "blocklist_behavior": "block", + "automod_thresholds": {}, + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "created_at": "2020-12-04T10:39:06.512021Z", + "updated_at": "2020-12-04T10:39:06.512021Z" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/own_user.json b/packages/stream_chat/test/fixtures/own_user.json new file mode 100644 index 00000000..dc6aabd1 --- /dev/null +++ b/packages/stream_chat/test/fixtures/own_user.json @@ -0,0 +1,100 @@ +{ + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:59:59.003453014Z", + "banned": false, + "online": true, + "devices": [ + { + "push_provider": "firebase", + "id": "cRS8elU4Q-qqdCAvHR2kSa:APA91bFy7MEgPyXbnFWi3uoanr_x8Vbi42JcWOXlg8p3vyIL5FuW4bjpVfamqQjYCgwDGxPA0C4qavOadE-uiKeGQJp6Sp5D2KDW9Od_BlDqzwEPJnVG9gC1zbj7NKCfXRqbOA2Wh2mW", + "created_at": "2020-04-23T14:36:21.838196Z", + "user_id": "super-band-9" + } + ], + "mutes": [], + "channel_mutes": [ + { + "user": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:59:59.003453014Z", + "banned": false, + "online": true, + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness", + "unread_count": 0, + "username": "Rioland" + }, + "channel": { + "id": "!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "type": "messaging", + "cid": "messaging:!members-Qsp7PpigdPkW0rJk0603y5GnTiF1iRfoDc4SAngMMmw", + "last_message_at": "2020-12-02T06:56:18.003432Z", + "created_at": "2020-11-30T10:25:32.494601Z", + "updated_at": "2020-11-30T10:25:32.494601Z", + "created_by": { + "id": "super-band-9", + "role": "user", + "created_at": "2020-03-03T16:48:28.853674Z", + "updated_at": "2021-05-26T03:22:20.296181Z", + "last_active": "2021-06-16T11:59:59.003453014Z", + "banned": false, + "online": true, + "unread_count": 0, + "username": "Rioland", + "image": "https://placehold.jp/150x150.png", + "invisible": false, + "name": "Proud darkness" + }, + "frozen": false, + "disabled": false, + "member_count": 2, + "config": { + "created_at": "2020-04-15T14:57:17.00966Z", + "updated_at": "2021-05-25T14:25:30.405621Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "blocklist": "profanity_en_2020_v1", + "blocklist_behavior": "block", + "automod_thresholds": {}, + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "created_at": "2020-12-04T10:39:06.512021Z", + "updated_at": "2020-12-04T10:39:06.512021Z" + } + ], + "total_unread_count": 0, + "unread_channels": 0, + "language": "", + "image": "https://placehold.jp/150x150.png", + "name": "Proud darkness", + "username": "Rioland" +} diff --git a/packages/stream_chat/test/fixtures/reaction.json b/packages/stream_chat/test/fixtures/reaction.json new file mode 100644 index 00000000..ce87ca1d --- /dev/null +++ b/packages/stream_chat/test/fixtures/reaction.json @@ -0,0 +1,18 @@ +{ + "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", + "user_id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }, + "type": "wow", + "score": 1, + "created_at": "2020-01-28T22:17:31.108742Z", + "updated_at": "2020-01-28T22:17:31.108742Z" +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/read.json b/packages/stream_chat/test/fixtures/read.json new file mode 100644 index 00000000..7dc596d3 --- /dev/null +++ b/packages/stream_chat/test/fixtures/read.json @@ -0,0 +1,7 @@ +{ + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e" + }, + "last_read": "2020-01-28T22:17:30.966485504Z", + "unread_messages": 10 +} \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/user.json b/packages/stream_chat/test/fixtures/user.json new file mode 100644 index 00000000..49b972a3 --- /dev/null +++ b/packages/stream_chat/test/fixtures/user.json @@ -0,0 +1,5 @@ +{ + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "test-role", + "name": "John" +} \ No newline at end of file diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index 18a1a3e7..6883d3b1 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -1,2728 +1,1938 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:dio/native_imp.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/event_type.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/own_user.dart'; -import 'package:stream_chat/src/models/reaction.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -class MockDio extends Mock implements DioForNative {} - -class FakeRequestOptions extends Fake implements RequestOptions {} - -class MockAttachmentUploader extends Mock implements AttachmentFileUploader {} - -class MockHttpClientAdapter extends Mock implements HttpClientAdapter {} +import '../fakes.dart'; +import '../matchers.dart'; +import '../mocks.dart'; void main() { - group('src/api/channel', () { - group('message', () { - test('sendMessage', () async { - final mockDio = MockDio(); + ChannelState _generateChannelState( + String channelId, + String channelType, { + bool mockChannelConfig = false, + }) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ); + final state = ChannelState(channel: channel); + return state; + } - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); + Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; + } - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final message = Message(text: 'hey', id: 'test'); + group('Non-Initialized Channel', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when( - () => mockDio.post( - '/channels/messaging/testid/message', - data: { - 'message': message.toJson(), - 'skip_push': false, - }, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.sendMessage(message); - - verify(() => - mockDio.post('/channels/messaging/testid/message', data: { - 'message': message.toJson(), - 'skip_push': false, - })).called(1); + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); }); - test('markRead', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - - final channelClient = client.channel('messaging', id: 'testid'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - await channelClient.watch(); - - when( - () => mockDio.post( - '/channels/messaging/testid/read', - data: {}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.markRead(); - - verify(() => mockDio.post('/channels/messaging/testid/read', - data: {})).called(1); - }); - - test('getReplies', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - const pagination = PaginationParams(); - - when(() => mockDio.get('/messages/messageid/replies', - queryParameters: pagination.toJson())).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.getReplies('messageid', pagination); - - verify(() => mockDio.get('/messages/messageid/replies', - queryParameters: pagination.toJson())).called(1); - }); - - test('sendAction', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - final data = {'test': true}; - - when(() => mockDio.post('/messages/messageid/action', data: { - 'id': 'testid', - 'type': 'messaging', - 'form_data': data, - 'message_id': 'messageid', - })).thenAnswer((_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.sendAction(Message(id: 'messageid'), data); - - verify(() => mockDio.post('/messages/messageid/action', data: { - 'id': 'testid', - 'type': 'messaging', - 'form_data': data, - 'message_id': 'messageid', - })).called(1); - }); - - test('getMessagesById', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final messageIds = ['a', 'b']; - - when(() => mockDio.get('/channels/messaging/testid/messages', - queryParameters: {'ids': messageIds.join(',')})).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.getMessagesById(messageIds); - - verify(() => mockDio.get('/channels/messaging/testid/messages', - queryParameters: {'ids': messageIds.join(',')})).called(1); - }); - - test('sendFile', () async { - final mockDio = MockDio(); - final mockUploader = MockAttachmentUploader(); - - const file = AttachmentFile( - path: 'filePath/fileName.pdf', - size: 100, - ); - const channelId = 'testId'; - const channelType = 'messaging'; - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - attachmentFileUploader: mockUploader, - ); - final channelClient = client.channel(channelType, id: channelId); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when(() => mockUploader.sendFile(file, channelId, channelType)) - .thenAnswer((_) async => SendFileResponse()); - - await channelClient.sendFile(file); - - verify(() => mockUploader.sendFile(file, channelId, channelType)) - .called(1); - }); - - test('sendImage', () async { - final mockDio = MockDio(); - final mockUploader = MockAttachmentUploader(); - - const image = AttachmentFile( - path: 'imagePath/imageName.jpeg', - size: 100, - ); - const channelId = 'testId'; - const channelType = 'messaging'; - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - attachmentFileUploader: mockUploader, - ); - final channelClient = client.channel(channelType, id: channelId); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when(() => mockUploader.sendImage(image, channelId, channelType)) - .thenAnswer((_) async => SendImageResponse()); - - await channelClient.sendImage(image); - - verify(() => mockUploader.sendImage(image, channelId, channelType)) - .called(1); - }); - - test('deleteFile', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - const url = 'url'; - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when( - () => mockDio.delete( - '/channels/messaging/testid/file', - queryParameters: {'url': url}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.deleteFile(url); - - verify(() => mockDio.delete('/channels/messaging/testid/file', - queryParameters: {'url': url})).called(1); - }); - - test('deleteImage', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - const url = 'url'; - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when( - () => mockDio.delete( - '/channels/messaging/testid/image', - queryParameters: {'url': url}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.deleteImage(url); - - verify(() => mockDio.delete('/channels/messaging/testid/image', - queryParameters: {'url': url})).called(1); - }); - - test('pinMessage should throw argument error', () { - final client = StreamChatClient('api-key'); - - final channelClient = client.channel('messaging', id: 'testid'); - - final message = Message(text: 'Hello'); - - expect( - () => channelClient.pinMessage(message, 'InvalidType'), - throwsArgumentError, - ); - }); - - test('should be pinned successfully', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final message = Message(text: 'Hello', id: 'test'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when( - () => mockDio.put( - '/messages/${message.id}', - data: anything, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.pinMessage(message, 30); - - verify(() => - mockDio.put('/messages/${message.id}', data: anything)) - .called(1); - }); - - test('should be pinned successfully with null timeout', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final message = Message(text: 'Hello', id: 'test'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when( - () => mockDio.put( - '/messages/${message.id}', - data: anything, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.pinMessage(message); - - verify(() => - mockDio.put('/messages/${message.id}', data: anything)) - .called(1); - }); - - test('should be unpinned successfully', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final message = Message(text: 'Hello', id: 'test'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState()), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - when( - () => mockDio.put( - '/messages/${message.id}', - data: anything, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.unpinMessage(message); - - verify(() => - mockDio.put('/messages/${message.id}', data: anything)) - .called(1); - }); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); }); - test('sendEvent', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post( - any(), - data: any(named: 'data'), - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - const event = Event(type: EventType.any); - - when( - () => mockDio.post( - '/channels/messaging/testid/event', - data: {'event': event.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.sendEvent(event); - - verify(() => mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})).called(1); + setUp(() { + channel = Channel(client, channelType, channelId); }); - test('keyStroke', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post( - any(), - data: any(named: 'data'), - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - const event = Event(type: EventType.typingStart); - - when( - () => mockDio.post( - '/channels/messaging/testid/event', - data: {'event': event.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.keyStroke(); - - verify(() => mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})).called(1); + tearDown(() { + channel.dispose(); }); - test('stopTyping', () async { - final mockDio = MockDio(); + test('should be able to set `extraData`', () { + expect(channel.extraData.isEmpty, isTrue); - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - const event = Event(type: EventType.typingStop); - - when( - () => mockDio.post( - '/channels/messaging/testid/event', - data: {'event': event.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), + expect( + () => channel.extraData = {'name': 'test-channel-name'}, + returnsNormally, ); - await channelClient.stopTyping(); + expect(channel.extraData.isEmpty, isFalse); + expect(channel.extraData.containsKey('name'), isTrue); + expect(channel.extraData['name'], 'test-channel-name'); + }); + }); - verify(() => mockDio.post('/channels/messaging/testid/event', - data: {'event': event.toJson()})).called(1); + // TODO : test all persistence related logic in this group + group('Initialized Channel with Persistence', () { + late final client = MockStreamChatClientWithPersistence(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelCid = '$channelType:$channelId'; + late Channel channel; + + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue>([]); + registerFallbackValue(FakeAttachmentFile()); + + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + retryTimeout: (_, __, ___) => Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + final event = Event(type: 'event.local'); + when(() => client.on(any(), any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // mock persistence client + final channelThreads = >{}; + when(() => client.chatPersistenceClient.getChannelThreads(channelCid)) + .thenAnswer((_) async => channelThreads); + final channelState = _generateChannelState(channelId, channelType); + when(() => client.chatPersistenceClient.getChannelStateByCid(channelCid)) + .thenAnswer((_) async => channelState); + when(() => client.chatPersistenceClient.updateMessages(channelCid, any())) + .thenAnswer((_) => Future.value()); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); }); - group('reactions', () { - test('sendReaction', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - - final user = OwnUser(id: 'test-id'); - - client.state.user = user; - - final message = Message(id: 'messageid'); - const reactionType = 'test'; - final reaction = Reaction(type: reactionType); - final channelClient = client.channel('messaging', id: 'testid'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - when( - () => mockDio.post( - '/messages/${message.id}/reaction', - data: { - 'reaction': { - 'type': reactionType, - }, - 'enforce_unique': false, - }, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({ - 'message': message, - 'reaction': reaction, - }), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.sendReaction( - message, - reactionType, - ); - - verify( - () => mockDio.post('/messages/messageid/reaction', data: { - 'reaction': { - 'type': reactionType, - }, - 'enforce_unique': false, - })).called(1); - }); - - test('deleteReaction', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - - client.state.user = OwnUser(id: 'test-id'); - - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.delete('/messages/messageid/reaction/test'), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.deleteReaction( - Message( - id: 'messageid', - ), - Reaction( - type: 'test', - createdAt: DateTime.now(), - user: User( - id: client.state.user?.id ?? '', - ), - ), - ); - - verify(() => - mockDio.delete('/messages/messageid/reaction/test')) - .called(1); - }); - - test('getReactions', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - const pagination = PaginationParams(); - - when( - () => mockDio.get( - '/messages/messageid/reactions', - queryParameters: pagination.toJson(), - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.getReactions('messageid', pagination); - - verify(() => mockDio.get('/messages/messageid/reactions', - queryParameters: pagination.toJson())).called(1); - }); + // Setting up a initialized channel + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); }); - group('channel', () { - test('addMembers', () async { - final mockDio = MockDio(); + tearDown(() { + channel.dispose(); + }); + }); - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); + group('Initialized Channel', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelCid = '$channelType:$channelId'; + late Channel channel; - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final channelModel = ChannelModel(cid: 'messaging:testid'); + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState(channel: channelModel)), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - final members = [Member(userId: 'vishal')]; - final memberIds = members.map((e) => e.userId!).toList(); - final message = Message(text: 'test'); - - when( - () => mockDio.post( - '/channels/messaging/testid', - data: {'add_members': memberIds, 'message': message.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({ - 'members': members, - 'message': message, - 'channel': channelModel, - }), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.addMembers(memberIds, message); - - verify(() => mockDio.post('/channels/messaging/testid', - data: {'add_members': memberIds, 'message': message.toJson()})) - .called(1); + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); }); - test('acceptInvite', () async { - final mockDio = MockDio(); + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + retryTimeout: (_, __, ___) => Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); + final event = Event(type: 'event.local'); + when(() => client.on(any(), any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final channelModel = ChannelModel(cid: 'messaging:testid'); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState(channel: channelModel)), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); - await channelClient.watch(); + // Setting up a initialized channel + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ); + channel = Channel.fromState(client, channelState); + }); - final message = Message(text: 'test'); + tearDown(() { + channel.dispose(); + }); - when( - () => mockDio.post( - '/channels/messaging/testid', - data: {'accept_invite': true, 'message': message.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({ - 'message': message, - 'channel': channelModel, - }), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), + test('should throw if trying to set `extraData`', () { + try { + channel.extraData = {'name': 'test-channel-name'}; + } catch (e) { + expect(e, isA()); + } + }); + + group('`.sendMessage`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id'); + + final sendMessageResponse = SendMessageResponse()..message = message; + + when(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).thenAnswer((_) async => sendMessageResponse); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sending), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), ); - await channelClient.acceptInvite(message); + final res = await channel.sendMessage(message); - verify(() => mockDio.post('/channels/messaging/testid', - data: {'accept_invite': true, 'message': message.toJson()})) - .called(1); - }); + expect(res, isNotNull); + expect(res.message.id, message.id); - group('query', () { - test('without id', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging'); - final options = { - 'watch': true, - 'state': false, - 'presence': true, - }; - - when(() => mockDio.post( - '/channels/messaging/query', - data: options, - )).thenAnswer( - (_) async => Response( - data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } - ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] - } - ''', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - final response = await channelClient.query(options: options); - - verify(() => mockDio.post( - '/channels/messaging/query', - data: options, - )).called(1); - expect(channelClient.id, response.channel?.id); - expect(channelClient.cid, response.channel?.cid); - }); - - test('with id', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final options = {'state': false}; - - when(() => mockDio.post('/channels/messaging/testid/query', - data: options)).thenAnswer( - (_) async => Response( - data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } - ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] - } - ''', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.query(options: options); - - verify(() => mockDio.post('/channels/messaging/testid/query', - data: options)).called(1); - }); - }); - - test('create', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging'); - final options = { - 'watch': false, - 'state': false, - 'presence': false, - }; - - when(() => mockDio.post('/channels/messaging/query', - data: options)).thenAnswer( - (_) async => Response( - data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } - ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] - } - ''', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - final response = await channelClient.create(); - - verify(() => mockDio.post('/channels/messaging/query', - data: options)).called(1); - expect(channelClient.id, response.channel?.id); - expect(channelClient.cid, response.channel?.cid); - }); - - test('watch', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging'); - final options = { - 'watch': true, - 'state': true, - 'presence': true, - }; - - when(() => mockDio.post('/channels/messaging/query', - data: options)).thenAnswer( - (_) async => Response( - data: r''' - { - "channel": { - "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "type": "messaging", - "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", - "last_message_at": "2020-01-28T22:17:31.204287Z", - "created_at": "2020-01-28T22:17:31.00187Z", - "updated_at": "2020-01-28T22:17:31.00187Z", - "created_by": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "frozen": false, - "member_count": 2, - "config": { - "created_at": "2020-01-29T12:59:14.291912835Z", - "updated_at": "2020-01-29T12:59:14.291912991Z", - "name": "messaging", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "Mia Denys", - "image": "https://randomuser.me/api/portraits/women/2.jpg" - }, - "messages": [ - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }, - { - "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", - "text": "Few can name a topfull mother that isn't a breezeless damage.", - "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.153518Z", - "updated_at": "2020-01-28T22:17:31.153518Z", - "mentioned_users": [] - }, - { - "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", - "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", - "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", - "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" - } - ], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.155428Z", - "updated_at": "2020-01-28T22:17:31.155428Z", - "mentioned_users": [] - }, - { - "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", - "text": "The carbons could be said to resemble smartish hoods.", - "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.157811Z", - "updated_at": "2020-01-28T22:17:31.157811Z", - "mentioned_users": [] - }, - { - "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", - "text": "Their software was, in this moment, a prolix feature.", - "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", - "type": "regular", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 1, - "created_at": "2020-01-28T22:17:31.158391Z", - "updated_at": "2020-01-28T22:17:31.158391Z", - "mentioned_users": [] - } - ], - "watcher_count": 1, - "read": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "last_read": "2020-01-28T22:17:31.016937728Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "last_read": "2020-01-28T22:17:31.018856448Z" - } - ], - "members": [ - { - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "role": "member", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - }, - { - "user": { - "id": "spring-voice-7", - "role": "user", - "created_at": "2020-01-28T22:17:30.834135Z", - "updated_at": "2020-01-28T22:17:31.186771Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", - "name": "Spring voice" - }, - "role": "owner", - "created_at": "2020-01-28T22:17:31.005135Z", - "updated_at": "2020-01-28T22:17:31.005135Z" - } - ] - } - ''', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - final response = await channelClient.watch({'presence': true}); - - verify(() => mockDio.post('/channels/messaging/query', - data: options)).called(1); - expect(channelClient.id, response.channel?.id); - expect(channelClient.cid, response.channel?.cid); - }); - - test('stopWatching', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post( - '/channels/messaging/testid/stop-watching', - data: {}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.stopWatching(); - - verify(() => mockDio.post( - '/channels/messaging/testid/stop-watching', - data: {}, + verify(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, )).called(1); }); - test('update', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', + test('with attachments should work just fine', () async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: 33 * index, path: 'test-file-path'), + ), ); - final channelClient = client.channel('messaging', id: 'testid'); - final channelModel = ChannelModel(cid: 'messaging:testid'); - when(() => mockDio.post( + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + final sendImageResponse = SendImageResponse()..file = 'test-image-url'; + final sendFileResponse = SendFileResponse()..file = 'test-file-url'; + + when(() => client.sendImage( any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState(channel: channelModel)), - statusCode: 200, - requestOptions: FakeRequestOptions(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendImageResponse); + + when(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendFileResponse); + + when(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).thenAnswer((_) async => SendMessageResponse() + ..message = message.copyWith( + attachments: attachments + .map((it) => + it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sending), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + verify(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(2); + + verify(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(1); + + verify(() => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + )).called(1); + }); + }); + + group('`.updateMessage`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id'); + + final updateMessageResponse = UpdateMessageResponse() + ..message = message; + + when(() => client.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((_) async => updateMessageResponse); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).called(1); + }); + + test('with attachments should work just fine', () async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: 33 * index, path: 'test-file-path'), + ), + ); + + final message = Message( + id: 'test-message-id', + attachments: attachments, + ); + + final sendImageResponse = SendImageResponse()..file = 'test-image-url'; + final sendFileResponse = SendFileResponse()..file = 'test-file-url'; + + when(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendImageResponse); + + when(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).thenAnswer((_) async => sendFileResponse); + + when(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + attachments: attachments + .map((it) => + it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.updating), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.attachments.length, message.attachments.length); + expect( + res.message.attachments.every( + (it) => it.uploadState == const UploadState.success(), + ), + isTrue, + ); + + verify(() => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(2); + + verify(() => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + )).called(1); + + verify(() => client.updateMessage( + any(that: isSameMessageAs(message)), + )).called(1); + }); + }); + + test('`.partialUpdateMessage`', () async { + final message = Message(id: 'test-message-id'); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = UpdateMessageResponse() + ..message = message.copyWith(text: set['text'], pinExpires: null); + + when( + () => client.partialUpdateMessage(message.id, set: set, unset: unset), + ).thenAnswer((_) async => updateMessageResponse); + + channel.state?.messagesStream.skip(1).listen(print); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + updateMessageResponse.message.copyWith( + status: MessageSendingStatus.sent, + ), + matchText: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); + + verify( + () => client.partialUpdateMessage(message.id, set: set, unset: unset), + ).called(1); + }); + + group('`.deleteMessage`', () { + test('should work fine', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + when(() => client.deleteMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.deleting), + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteMessage(message); + + expect(res, isNotNull); + + verify(() => client.deleteMessage(messageId)).called(1); + }); + + test( + 'should directly update the state with message as deleted if the state is sending or failed', + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + status: MessageSendingStatus.sending, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteMessage(message); + + expect(res, isNotNull); + }, + ); + }); + + group('`.pinMessage`', () { + test('should work fine without passing timeoutOrExpirationDate', + () async { + final message = Message(id: 'test-message-id'); + + when(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: null, + status: MessageSendingStatus.sent, + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + verify(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + }); + + test( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + () async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 300; // 300 seconds + + when(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: DateTime.now().add( + const Duration(seconds: timeoutOrExpirationDate), + ), + status: MessageSendingStatus.sent, )); - await channelClient.watch(); + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); - final message = Message(text: 'test'); + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); - when( - () => mockDio.post( - '/channels/messaging/testid', - data: { - 'message': message.toJson(), - 'data': {'test': true}, - }, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({ - 'channel': channelModel, - 'message': message, - }), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + verify(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + }, + ); + + test( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + () async { + final message = Message(id: 'test-message-id'); + final timeoutOrExpirationDate = + DateTime.now().add(const Duration(days: 3)); // 3 days + + when(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + status: MessageSendingStatus.sent, + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + verify(() => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + }, + ); + + test( + 'should throw if invalid timeoutOrExpirationDate is passed', + () async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 'invalid-value'; + + try { + await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + test('`.unpinMessage`', () async { + final message = Message(id: 'test-message-id', pinned: true); + + when(() => client.partialUpdateMessage( + message.id, + set: {'pinned': false}, + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: false, + status: MessageSendingStatus.sent, + )); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(status: MessageSendingStatus.sent), + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.unpinMessage(message); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + verify(() => client.partialUpdateMessage( + message.id, + set: {'pinned': false}, + )).called(1); + }); + + group('`.search`', () { + final filter = Filter.in_('cid', const [channelCid]); + + test('should work fine with `query`', () async { + const query = 'test-search-query'; + const sort = [SortOption('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => GetMessageResponse()); + + when(() => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, ); - await channelClient.update({'test': true}, message); + final res = await channel.search( + query: query, + sort: sort, + paginationParams: pagination, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + verify(() => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + + test('should work fine with `messageFilters`', () async { + final messageFilters = Filter.query('key', 'text'); + const sort = [SortOption('test-sort-field')]; + const pagination = PaginationParams(); + + final results = List.generate(3, (index) => GetMessageResponse()); + + when(() => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, + ); + + final res = await channel.search( + sort: sort, + paginationParams: pagination, + messageFilters: messageFilters, + ); + + expect(res, isNotNull); + expect(res.results.length, results.length); + + verify(() => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + }); + + test('`.deleteFile`', () async { + const url = 'test-file-url'; + + when(() => client.deleteFile(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.deleteFile(url); + + expect(res, isNotNull); + + verify(() => client.deleteFile(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))).called(1); + }); + + test('`.deleteImage`', () async { + const url = 'test-image-url'; + + when(() => client.deleteImage(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.deleteImage(url); + + expect(res, isNotNull); + + verify(() => client.deleteImage(url, channelId, channelType, + cancelToken: any(named: 'cancelToken'))).called(1); + }); + + test('`.sendEvent`', () async { + final event = Event(type: 'event.local'); + + when(() => client.sendEvent(channelId, channelType, event)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.sendEvent(event); + + expect(res, isNotNull); + + verify(() => client.sendEvent(channelId, channelType, event)).called(1); + }); + + group('`.sendReaction`', () { + test('should work fine', () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction(message, type); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + verify(() => client.sendReaction(message.id, type)).called(1); + }); + + test( + 'should restore previous message if `client.sendReaction` throws', + () async { + const type = 'test-reaction-type'; + final message = Message(id: 'test-message-id'); + + final reaction = Reaction(type: type, messageId: message.id); + + when(() => client.sendReaction(message.id, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + reactionCounts: {type: 1}, + reactionScores: {type: 1}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + try { + await channel.sendReaction(message, type); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.sendReaction(message.id, type)).called(1); + }, + ); + + test( + 'should override previous reaction if present and `enforceUnique` is true', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const prevType = 'test-reaction-type'; + final prevReaction = Reaction( + type: prevType, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionScores: const {prevType: 1}, + reactionCounts: const {prevType: 1}, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + when(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).thenAnswer( + (_) async => SendReactionResponse() + ..message = newMessage + ..reaction = newReaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + newMessage.copyWith(status: MessageSendingStatus.sent), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + type, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + verify(() => client.sendReaction( + messageId, + type, + enforceUnique: enforceUnique, + )).called(1); + }, + ); + }); + + group('`.deleteReaction`', () { + test('should work fine', () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + final res = await channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + verify(() => client.deleteReaction(messageId, type)).called(1); + }); + + test( + 'should restore prev message state if `client.deleteReaction` throws', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionScores: const {type: 1}, + reactionCounts: const {type: 1}, + ); + + when(() => client.deleteReaction(messageId, type)) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + status: MessageSendingStatus.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchSendingStatus: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchSendingStatus: true, + ), + ], + ]), + ); + + try { + await channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.deleteReaction(messageId, type)).called(1); + }, + ); + }); + + test('`.update`', () async { + const channelData = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + final updateMessage = Message( + id: 'test-message-id', + text: 'updated channel', + ); + + final channelModel = ChannelModel( + cid: channelCid, + extraData: channelData, + ); + + when(() => client.updateChannel(channelId, channelType, channelData, + message: any(named: 'message'))).thenAnswer( + (_) async => UpdateChannelResponse() + ..channel = channelModel + ..message = updateMessage, + ); + + final res = await channel.update(channelData, updateMessage); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.channel.extraData, channelData); + expect(res.message?.id, updateMessage.id); + + verify(() => client.updateChannel(channelId, channelType, channelData, + message: any(named: 'message'))).called(1); + }); + + test('`.updatePartial`', () async { + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + + const unset = ['tag', 'last_name']; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: { + 'coolness': 999, + ...set, + }, + ); + + when(() => client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + )).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updatePartial(set: set, unset: unset); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect( + res.channel.extraData, + {'coolness': 999, ...set}, + ); + + verify(() => client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + )).called(1); + }); + + test('`.delete`', () async { + when(() => client.deleteChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.delete(); + + expect(res, isNotNull); + + verify(() => client.deleteChannel(channelId, channelType)).called(1); + }); + + test('`.truncate`', () async { + when(() => client.truncateChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.truncate(); + + expect(res, isNotNull); + + verify(() => client.truncateChannel(channelId, channelType)).called(1); + }); + + test('`.acceptInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Accepted'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.acceptChannelInvite(channelId, channelType, + message: any(named: 'message'))).thenAnswer( + (_) async => AcceptInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.acceptInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.acceptChannelInvite(channelId, channelType, + message: any(named: 'message'))).called(1); + }); + + test('`.rejectInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Rejected'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.rejectChannelInvite(channelId, channelType, + message: any(named: 'message'))).thenAnswer( + (_) async => RejectInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.rejectInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.rejectChannelInvite(channelId, channelType, + message: any(named: 'message'))).called(1); + }); + + test('`.addMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Added'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.addChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => AddMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.addMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.addChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + test('`.inviteMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Invited'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.inviteChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => InviteMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.inviteMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.inviteChannelMembers( + channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + test('`.removeMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members + .map((it) => it.userId) + .whereType() + .toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Removed'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.removeChannelMembers(channelId, channelType, memberIds, + message: any(named: 'message'))).thenAnswer( + (_) async => RemoveMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.removeMembers(memberIds, message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify(() => client.removeChannelMembers( + channelId, channelType, memberIds, + message: any(named: 'message'))).called(1); + }); + + group('`.sendAction`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + when( + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()); + + final res = await channel.sendAction(message, formData); + + expect(res, isNotNull); verify( - () => mockDio.post('/channels/messaging/testid', data: { - 'message': message.toJson(), - 'data': {'test': true}, - }), + () => client.sendAction(channelId, channelType, message.id, formData), ).called(1); }); - test('delete', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); + test('should emit received message if not null', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; when( - () => mockDio.delete('/channels/messaging/testid'), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()..message = message); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message, + matchSendingStatus: true, + ), + ], + ]), ); - await channelClient.delete(); + final res = await channel.sendAction(message, formData); - verify(() => mockDio.delete('/channels/messaging/testid')) - .called(1); - }); - - test('truncate', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post('/channels/messaging/testid/truncate'), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.truncate(); - - verify(() => - mockDio.post('/channels/messaging/testid/truncate')) - .called(1); - }); - - test('rejectInvite', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final channelModel = ChannelModel(cid: 'messaging:testid'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState(channel: channelModel)), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - final message = Message(text: 'test'); - - when( - () => mockDio.post( - '/channels/messaging/testid', - data: {'reject_invite': true, 'message': message.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({ - 'message': message, - 'channel': channelModel, - }), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.rejectInvite(message); - - verify(() => mockDio.post('/channels/messaging/testid', - data: {'reject_invite': true, 'message': message.toJson()})) - .called(1); - }); - - test('inviteMembers', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final channelModel = ChannelModel(cid: 'messaging:testid'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState(channel: channelModel)), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - final members = [Member(userId: 'vishal')]; - final memberIds = members.map((e) => e.userId!).toList(); - final message = Message(text: 'test'); - - when( - () => mockDio.post( - '/channels/messaging/testid', - data: {'invites': memberIds, 'message': message.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({ - 'members': members, - 'message': message, - 'channel': channelModel, - }), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.inviteMembers(memberIds, message); - - verify(() => mockDio.post('/channels/messaging/testid', - data: {'invites': memberIds, 'message': message.toJson()})) - .called(1); - }); - - test('removeMembers', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - final channelModel = ChannelModel(cid: 'messaging:testid'); - - when(() => mockDio.post( - any(), - data: any(named: 'data'), - )).thenAnswer((_) async => Response( - data: jsonEncode(ChannelState(channel: channelModel)), - statusCode: 200, - requestOptions: FakeRequestOptions(), - )); - - await channelClient.watch(); - - final members = [Member(userId: 'vishal')]; - final memberIds = members.map((e) => e.userId!).toList(); - final message = Message(text: 'test'); - - when( - () => mockDio.post( - '/channels/messaging/testid', - data: {'remove_members': memberIds, 'message': message.toJson()}, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({ - 'members': members, - 'message': message, - 'channel': channelModel, - }), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.removeMembers(memberIds, message); - - verify(() => mockDio.post('/channels/messaging/testid', data: { - 'remove_members': memberIds, - 'message': message.toJson() - })).called(1); - }); - - test('hide', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post( - any(), - data: any(named: 'data'), - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - when( - () => mockDio.post( - '/channels/messaging/testid/hide', - data: {'clear_history': true}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.hide(clearHistory: true); - - verify(() => mockDio.post('/channels/messaging/testid/hide', - data: {'clear_history': true})).called(1); - }); - - test('show', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post( - any(), - data: any(named: 'data'), - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - when( - () => mockDio.post('/channels/messaging/testid/show'), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.show(); - - verify(() => mockDio.post('/channels/messaging/testid/show')) - .called(1); - }); - - test('banUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post( - any(), - data: any(named: 'data'), - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - when( - () => mockDio.post('/moderation/ban', data: { - 'test': true, - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - }), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - final options = {'test': true}; - await channelClient.banUser('test-id', options); - - verify(() => mockDio.post('/moderation/ban', data: { - 'test': true, - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - })).called(1); - }); - - test('unbanUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - tokenProvider: (_) async => '', - ); - final channelClient = client.channel('messaging', id: 'testid'); - - when( - () => mockDio.post( - any(), - data: any(named: 'data'), - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - await channelClient.watch(); - - when( - () => mockDio.delete( - '/moderation/ban', - queryParameters: { - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - }, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.unbanUser('test-id'); + expect(res, isNotNull); + expect(res.message?.id, message.id); verify( - () => mockDio.delete('/moderation/ban', queryParameters: { - 'target_user_id': 'test-id', - 'type': 'messaging', - 'id': 'testid', - }), + () => client.sendAction(channelId, channelType, message.id, formData), ).called(1); }); }); + + test('`.markRead`', () async { + const messageId = 'test-message-id'; + + when(() => client.markChannelRead(channelId, channelType, + messageId: messageId)).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.markRead(messageId: messageId); + + expect(res, isNotNull); + expect(client.state.totalUnreadCount, 0); + + verify(() => client.markChannelRead(channelId, channelType, + messageId: messageId)).called(1); + }); + + group('`.watch`', () { + test('should work fine', () async { + when(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer( + (_) async => _generateChannelState(channelId, channelType), + ); + + final res = await channel.watch(); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + expect(res.channel?.cid, channelCid); + + verify(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + + test('should rethrow if `.query` throws', () async { + when(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.watch(); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + }); + + test('`.stopWatching`', () async { + when(() => client.stopChannelWatching(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.stopWatching(); + + expect(res, isNotNull); + + verify(() => client.stopChannelWatching(channelId, channelType)) + .called(1); + }); + + test('`.getReplies`', () async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); + + when(() => client.getReplies(parentId)).thenAnswer( + (_) async => QueryRepliesResponse()..messages = messages, + ); + + final res = await channel.getReplies(parentId); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + expect(res.messages.every((it) => it.parentId == parentId), isTrue); + + verify(() => client.getReplies(parentId)).called(1); + }); + + test('`.getReactions`', () async { + const messageId = 'test-message-id'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when(() => client.getReactions(messageId)).thenAnswer( + (_) async => QueryReactionsResponse()..reactions = reactions, + ); + + final res = await channel.getReactions(messageId); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify(() => client.getReactions(messageId)).called(1); + }); + + test('`.getMessagesById`', () async { + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + final messageIds = messages.map((it) => it.id).toList(growable: false); + + when(() => client.getMessagesById(channelId, channelType, messageIds)) + .thenAnswer( + (_) async => GetMessagesByIdResponse()..messages = messages, + ); + + final res = await channel.getMessagesById(messageIds); + + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + verify( + () => client.getMessagesById(channelId, channelType, messageIds), + ).called(1); + }); + + test('`.translateMessage`', () async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'ā¤¨ā¤Žā¤¸āĨā¤¤āĨ‡'; + final translatedMessage = TranslatedMessage(const { + language: translatedMessageText, + }); + + when(() => client.translateMessage(messageId, language)).thenAnswer( + (_) async => TranslateMessageResponse()..message = translatedMessage, + ); + + final res = await channel.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + verify(() => client.translateMessage(messageId, language)).called(1); + }); + + group('`.query`', () { + test('should work fine', () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + final res = await channel.query(); + + expect(res, isNotNull); + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test('should rethrow if `client.queryChannel` throws', () async { + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.query(); + } catch (e) { + expect(e, isA()); + } + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + }); + + test('`.queryMembers`', () async { + final filter = Filter.in_('cid', const [channelCid]); + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + when(() => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryMembersResponse()..members = members); + + final res = await channel.queryMembers(filter: filter); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify(() => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).called(1); + }); + + test('`.mute`', () async { + when(() => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.mute(); + + expect(res, isNotNull); + + verify(() => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + )).called(1); + }); + + test('`.unmute`', () async { + when( + () => client.unmuteChannel(channelCid), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unmute(); + + expect(res, isNotNull); + + verify( + () => client.unmuteChannel(channelCid), + ).called(1); + }); + + test('`.banUser`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when(() => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.banUser(userId, options); + + expect(res, isNotNull); + + verify(() => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).called(1); + }); + + test('`.unbanUser`', () async { + const userId = 'test-user-id'; + + when(() => client.unbanUser(userId, any())) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unbanUser(userId); + + expect(res, isNotNull); + + verify(() => client.unbanUser(userId, any())).called(1); + }); + + test('`.shadowBan`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when(() => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.shadowBan(userId, options); + + expect(res, isNotNull); + + verify(() => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + )).called(1); + }); + + test('`.removeShadowBan`', () async { + const userId = 'test-user-id'; + + when(() => client.removeShadowBan(userId, any())) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.removeShadowBan(userId); + + expect(res, isNotNull); + + verify(() => client.removeShadowBan(userId, any())).called(1); + }); + + test('`.hide`', () async { + const clearHistory = true; + + when(() => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + )).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.hide(clearHistory: clearHistory); + + expect(res, isNotNull); + + verify(() => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + )).called(1); + }); + + test('`.show`', () async { + when(() => client.showChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await channel.show(); + + expect(res, isNotNull); + + verify(() => client.showChannel(channelId, channelType)).called(1); + }); + + test('`.on`', () async { + const eventType = 'test.event'; + final event = Event(type: eventType, cid: channelCid); + + when(() => client.on(eventType, any(), any(), any())) + .thenAnswer((_) => Stream.value(event)); + + expectLater(channel.on(eventType), emitsInOrder([event])); + + verify(() => client.on(eventType, any(), any(), any())).called(1); + }); + + group( + '`.keyStroke`', + () { + test('should return if `config.typingEvents` is false', () async { + when(() => channel.config?.typingEvents).thenReturn(false); + + final typingEvent = Event(type: EventType.typingStart); + + await channel.keyStroke(); + + verifyNever(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )); + }); + + test( + 'should send `typingStart` event if there is not already a typingEvent or the difference between the two is >= 2 seconds', + () async { + final typingEvent = Event(type: EventType.typingStart); + + when(() => channel.config?.typingEvents).thenReturn(true); + + when(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )).thenAnswer((_) async => EmptyResponse()); + + await channel.keyStroke(); + + verify(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + )).called(1); + }, + ); + }, + ); + + group('`.stopTyping`', () { + test('should return if `config.typingEvents` is false', () async { + when(() => channel.config?.typingEvents).thenReturn(false); + + final typingStopEvent = Event(type: EventType.typingStop); + + await channel.keyStroke(); + + verifyNever(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )); + }); + + test('should send `typingStop` successfully', () async { + final typingStopEvent = Event(type: EventType.typingStop); + + when(() => channel.config?.typingEvents).thenReturn(true); + + when(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )).thenAnswer((_) async => EmptyResponse()); + + await channel.stopTyping(); + + verify(() => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + )).called(1); + }); + }); }); } diff --git a/packages/stream_chat/test/src/api/client_test.dart b/packages/stream_chat/test/src/api/client_test.dart new file mode 100644 index 00000000..13783865 --- /dev/null +++ b/packages/stream_chat/test/src/api/client_test.dart @@ -0,0 +1,2260 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/ws/connection_status.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/scaffolding.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../matchers.dart'; +import '../mocks.dart'; +import '../utils.dart'; + +void main() { + group('Fake web-socket connection functions', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeUser()); + }); + + setUp(() { + final ws = FakeWebSocket(); + client = StreamChatClient(apiKey, ws: ws, chatApi: api); + }); + + tearDown(() { + client.dispose(); + }); + + test('`.connectUser` should work fine', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'fake-connection-id', + me: OwnUser.fromUser(user), + ); + + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectUser(user, token); + expect(res, isNotNull); + expect(res.type, event.type); + expect(res.connectionId, event.connectionId); + expect(res.me, isSameUserAs(user)); + }); + + test('`.connectUserWithProvider` should work fine', () async { + final user = User(id: 'test-user-id'); + Future tokenProvider(String userId) async { + expect(userId, user.id); + return Token.development(userId).rawValue; + } + + final event = Event( + type: EventType.healthCheck, + connectionId: 'fake-connection-id', + me: OwnUser.fromUser(user), + ); + + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res.type, event.type); + expect(res.connectionId, event.connectionId); + expect(res.me, isSameUserAs(user)); + }); + + group('`.connectGuestUser`', () { + test('should work fine', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenAnswer( + (_) async => ConnectGuestUserResponse() + ..user = user + ..accessToken = token, + ); + + final event = Event( + type: EventType.healthCheck, + connectionId: 'fake-connection-id', + me: OwnUser.fromUser(user), + ); + + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectGuestUser(user); + expect(res, isNotNull); + expect(res.type, event.type); + expect(res.connectionId, event.connectionId); + expect(res.me, isSameUserAs(user)); + + verify( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).called(1); + }); + + test('should throw if `.getGuestUser` fails', () async { + final user = User(id: 'test-user-id'); + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + client.wsConnectionStatusStream, + emitsInOrder([ + // only emits the seed -> disconnected status + // as the call never reaches `ws.connect` + ConnectionStatus.disconnected, + ]), + ); + + try { + await client.connectGuestUser(user); + } catch (e) { + expect(e, isA()); + } + + verify( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).called(1); + }); + }); + + test('`.connectAnonymousUser` should work fine', () async { + expectLater( + // skipping first seed status -> ConnectionStatus.disconnected + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + expect(res.type, EventType.healthCheck); + expect(res.connectionId, 'fake-connection-id'); + expect(res.me, isNotNull); + }); + + group('`.openConnection`', () { + test('should throw if state does not contain user', () async { + expect(client.state.user, isNull); + try { + await client.openConnection(); + } catch (e) { + expect(e, isA()); + } + }); + + test('should throw if connection is already in progress', () async { + expect(client.state.user, isNull); + try { + await client.connectAnonymousUser(); + await client.openConnection(); + } catch (e) { + expect(e, isA()); + final err = e as StreamChatError; + expect( + err.message.contains('Connection already in progress for'), + isTrue, + ); + } + }); + + test('should throw if connection is already available', () async { + expect(client.state.user, isNull); + try { + await client.connectAnonymousUser(); + // waiting 300ms for `wsConnectionStatusStream` to emit + await delay(300); + + await client.openConnection(); + } catch (e) { + expect(e, isA()); + final err = e as StreamChatError; + expect( + err.message.contains('Connection already available for'), + isTrue, + ); + } + }); + + test('should open connection for closed connection', () async { + expectLater( + client.wsConnectionStatusStream.skip(1), + emitsInOrder([ + // initial connectUser + ConnectionStatus.connecting, + ConnectionStatus.connected, + // close connection + ConnectionStatus.disconnected, + // open connection + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + await client.connectAnonymousUser(); + // waiting 300ms for `wsConnectionStatusStream` to emit + await delay(300); + + client.closeConnection(); + + await client.openConnection(); + }); + }); + }); + + group('Fake web-socket connection functions failure', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeUser()); + }); + + setUp(() { + final ws = FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + }); + + tearDown(() { + client.dispose(); + }); + + test('`.connectUser` should throw if `ws.connect` fails', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + try { + await client.connectUser(user, token); + } catch (e) { + expect(e, isA()); + } + }); + + test( + '`.connectUserWithProvider` should throw if `ws.connect` fails', + () async { + final user = User(id: 'test-user-id'); + Future tokenProvider(String userId) async { + expect(userId, user.id); + return Token.development(userId).rawValue; + } + + try { + await client.connectUserWithProvider(user, tokenProvider); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test('`.connectGuestUser` should throw if `ws.connect` fails', () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenAnswer( + (_) async => ConnectGuestUserResponse() + ..user = user + ..accessToken = token, + ); + + try { + await client.connectGuestUser(user); + } catch (e) { + expect(e, isA()); + } + verify( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).called(1); + }); + + test( + '`.connectAnonymousUser` should throw if `ws.connect` fails', + () async { + try { + await client.connectAnonymousUser(); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + group('Fake web-socket connection function with failure and persistence', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + late final persistence = MockPersistenceClient(); + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeUser()); + }); + + setUp(() { + final ws = FakeWebSocketWithConnectionError(); + client = StreamChatClient(apiKey, chatApi: api, ws: ws) + ..chatPersistenceClient = persistence; + }); + + tearDown(() { + client.dispose(); + }); + + test( + '`.connectUser` should connect successfully if persistence contains event', + () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user)); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await client.connectUser(user, token); + expect(res, isNotNull); + expect(res.connectionId, 'test-connection-id'); + expect(res.me?.id, user.id); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + test( + '`.connectUserWithProvider` should connect successfully if persistence contains event', + () async { + final user = User(id: 'test-user-id'); + Future tokenProvider(String userId) async { + expect(userId, user.id); + return Token.development(userId).rawValue; + } + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user)); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + final res = await client.connectUserWithProvider(user, tokenProvider); + expect(res, isNotNull); + expect(res.connectionId, 'test-connection-id'); + expect(res.me?.id, user.id); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + + test( + '`.connectGuestUser` should connect successfully if persistence contains event', + () async { + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + final event = Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user)); + when(persistence.getConnectionInfo).thenAnswer((_) async => event); + + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .thenAnswer( + (_) async => ConnectGuestUserResponse() + ..user = user + ..accessToken = token, + ); + + final res = await client.connectGuestUser(user); + expect(res, isNotNull); + expect(res.connectionId, 'test-connection-id'); + expect(res.me?.id, user.id); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + verify(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) + .called(1); + verifyNoMoreInteractions(api.guest); + }, + ); + + test( + '`.connectAnonymousUser` should connect successfully if persistence contains event', + () async { + final user = User(id: 'test-user-id'); + + when(persistence.getConnectionInfo).thenAnswer( + (invocation) async => Event( + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + ); + + final res = await client.connectAnonymousUser(); + expect(res, isNotNull); + expect(res.connectionId, 'test-connection-id'); + expect(res.me?.id, user.id); + + verify(persistence.getConnectionInfo).called(1); + verifyNoMoreInteractions(persistence); + }, + ); + }); + + group('Client with connected user with persistence', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + late final ws = FakeWebSocket(); + late final persistence = MockPersistenceClient(); + + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeEvent()); + registerFallbackValue(const PaginationParams()); + registerFallbackValue(FakeChannelState()); + }); + + setUp(() async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws) + ..chatPersistenceClient = persistence; + await client.connectUser(user, token); + await delay(300); + expect(client.persistenceEnabled, isTrue); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + }); + + tearDown(() { + client.dispose(); + }); + + group('`.sync`', () { + test( + 'should update persistence connectionInfo and lastSync when sync succeeds', + () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + when(() => api.general.sync(cids, lastSyncAt)) + .thenAnswer((_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ]); + + when(() => persistence.updateConnectionInfo(any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())) + .thenAnswer((_) => Future.value()); + + await client.sync(cids: cids, lastSyncAt: lastSyncAt); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + verify(() => api.general.sync(cids, lastSyncAt)).called(1); + }, + ); + + test( + 'should work fine if persistence contains sync params', + () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + when(persistence.getChannelCids).thenAnswer((_) async => cids); + when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); + + when(() => api.general.sync(cids, lastSyncAt)) + .thenAnswer((_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id', text: 'Hey!'), + ), + ]); + + when(() => persistence.updateConnectionInfo(any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())) + .thenAnswer((_) => Future.value()); + + await client.sync(); + + verify(() => persistence.updateConnectionInfo(any())).called(1); + verify(() => persistence.updateLastSyncAt(any())).called(1); + verify(() => api.general.sync(cids, lastSyncAt)).called(1); + verify(persistence.getChannelCids).called(1); + verify(persistence.getLastSyncAt).called(1); + }, + ); + }); + + group('`.queryChannels`', () { + test( + 'should emit channels twice if persistence contains some channels', + () async { + final persistentChannelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'p-test-type-$index:p-test-id-$index'), + ), + ); + + when(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer((_) async => persistentChannelStates); + + final channelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = channelStates, + ); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => {}); + when(() => persistence.updateMessages(any(), any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: + any(named: 'pinnedMessagePagination'))).thenAnswer( + (invocation) async => ChannelState( + channel: ChannelModel(cid: invocation.positionalArguments.first), + ), + ); + when(() => persistence.updateChannelQueries(any(), any(), + clearQueryCache: any(named: 'clearQueryCache'))) + .thenAnswer((_) => Future.value()); + + expectLater( + client.queryChannels(), + emitsInOrder([ + // emits persistent channels first + persistentChannelStates.map(isCorrectChannelFor), + // makes api call and emits network fetched channels + channelStates.map(isCorrectChannelFor), + ]), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => persistence.getChannelThreads(any())) + .called((persistentChannelStates + channelStates).length); + verify(() => persistence.updateMessages(any(), any())) + .called((persistentChannelStates + channelStates).length); + verify( + () => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: any(named: 'pinnedMessagePagination')), + ).called((persistentChannelStates + channelStates).length); + verify(() => persistence.updateChannelQueries(any(), any(), + clearQueryCache: any(named: 'clearQueryCache'))).called(1); + }, + ); + + test( + 'should never rethrow network call if persistence already emitted some channels', + () async { + final persistentChannelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'p-test-type-$index:p-test-id-$index'), + ), + ); + + when(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer((_) async => persistentChannelStates); + + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => {}); + when(() => persistence.updateMessages(any(), any())) + .thenAnswer((_) => Future.value()); + when(() => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: + any(named: 'pinnedMessagePagination'))).thenAnswer( + (invocation) async => ChannelState( + channel: ChannelModel(cid: invocation.positionalArguments.first), + ), + ); + + expectLater( + client.queryChannels(), + emitsInOrder([ + // emits persistent channels + persistentChannelStates.map(isCorrectChannelFor), + ]), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => persistence.getChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + + verify(() => persistence.getChannelThreads(any())) + .called(persistentChannelStates.length); + verify(() => persistence.updateMessages(any(), any())) + .called(persistentChannelStates.length); + verify( + () => persistence.getChannelStateByCid(any(), + messagePagination: any(named: 'messagePagination'), + pinnedMessagePagination: any(named: 'pinnedMessagePagination')), + ).called(persistentChannelStates.length); + }, + ); + }); + + test('`.disconnectUser` should reset state and user', () async { + expect(client.state.user, isNotNull); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + + expectLater( + // skipping initial connected value + client.wsConnectionStatusStream.skip(1), + emits(ConnectionStatus.disconnected), + ); + + await client.disconnectUser(); + + expect(client.state.user, isNull); + expect(client.wsConnectionStatus, ConnectionStatus.disconnected); + }); + }); + + group('Client with connected user without persistence', () { + const apiKey = 'test-api-key'; + late final api = FakeChatApi(); + late final ws = FakeWebSocket(); + + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late StreamChatClient client; + + setUpAll(() { + // fallback values + registerFallbackValue(FakeEvent()); + registerFallbackValue(FakeMessage()); + registerFallbackValue(const PaginationParams()); + }); + + setUp(() async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + expect(client.persistenceEnabled, isFalse); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + }); + + tearDown(() { + client.dispose(); + }); + + group('`.sync`', () { + test('should work fine', () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + when(() => api.general.sync(cids, lastSyncAt)) + .thenAnswer((_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ]); + + await client.sync(cids: cids, lastSyncAt: lastSyncAt); + + verify(() => api.general.sync(cids, lastSyncAt)).called(1); + }); + + test('should return if `cids` is not available', () async { + expect(client.sync, returnsNormally); + verifyNever(() => api.general.sync(any(), any())); + }); + + test('should return if `lastSyncAt` is not available', () async { + expect(() => client.sync(cids: ['test-cid-1']), returnsNormally); + verifyNever(() => api.general.sync(any(), any())); + }); + }); + + group('`.queryChannels`', () { + test('should work fine without persistent channels', () async { + final channelStates = List.generate( + 3, + (index) => ChannelState( + channel: ChannelModel(cid: 'test-type-$index:test-id-$index'), + ), + ); + + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = channelStates, + ); + + expectLater( + client.queryChannels(), + emitsInOrder([channelStates.map(isCorrectChannelFor)]), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }); + + test( + 'should rethrow if `.queryChannelsOnline` throws and persistence channels are empty', + () async { + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + client.queryChannels(), + emitsError(isA()), + ); + + // Hack as `teardown` gets called even + // before our stream starts emitting data + await delay(300); + + verify(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).called(1); + }, + ); + }); + + test('`.queryUsers`', () async { + final users = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + + when(() => api.user.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + expectLater( + // skipping initial seed event -> {} users + client.state.usersStream.skip(1), + emitsInOrder([ + {for (var user in users) user.id: user}, + ]), + ); + + final res = await client.queryUsers(); + expect(res, isNotNull); + expect(res.users.length, users.length); + + verify(() => api.user.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).called(1); + verifyNoMoreInteractions(api.user); + }); + + test('`.search`', () async { + const cid = 'test-type:test-id'; + final filter = Filter.in_('cid', const [cid]); + + final messages = List.generate( + 3, + (index) => GetMessageResponse() + ..channel = ChannelModel(cid: cid) + ..message = Message(id: 'test-message-id-$index'), + ); + + when(() => api.general.searchMessages(filter, + query: any(named: 'query'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + messageFilters: any(named: 'messageFilters'))) + .thenAnswer( + (_) async => SearchMessagesResponse()..results = messages); + + final res = await client.search(filter); + expect(res, isNotNull); + expect(res.results.length, messages.length); + + verify(() => api.general.searchMessages(filter, + query: any(named: 'query'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + messageFilters: any(named: 'messageFilters'))).called(1); + verifyNoMoreInteractions(api.general); + }); + + test('`.sendFile`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final file = AttachmentFile(size: 33, path: 'test-file-path'); + + const fileUrl = 'test-file-url'; + + when(() => api.fileUploader.sendFile(file, channelId, channelType)) + .thenAnswer((_) async => SendFileResponse()..file = fileUrl); + + final res = await client.sendFile(file, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + verify(() => api.fileUploader.sendFile(file, channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.sendImage`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final image = AttachmentFile(size: 33, path: 'test-image-path'); + + const fileUrl = 'test-image-url'; + + when(() => api.fileUploader.sendImage(image, channelId, channelType)) + .thenAnswer((_) async => SendImageResponse()..file = fileUrl); + + final res = await client.sendImage(image, channelId, channelType); + expect(res, isNotNull); + expect(res.file, fileUrl); + + verify(() => api.fileUploader.sendImage(image, channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.deleteFile`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const fileUrl = 'test-file-url'; + + when(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteFile(fileUrl, channelId, channelType); + expect(res, isNotNull); + + verify(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.deleteImage`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const imageUrl = 'test-image-url'; + + when(() => api.fileUploader.deleteImage(imageUrl, channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteImage(imageUrl, channelId, channelType); + expect(res, isNotNull); + + verify( + () => api.fileUploader.deleteImage(imageUrl, channelId, channelType), + ).called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.updateChannel`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const data = {'name': 'test-channel'}; + + when(() => api.channel.updateChannel(channelId, channelType, data)) + .thenAnswer((invocation) async => UpdateChannelResponse() + ..channel = ChannelModel( + id: channelId, + type: channelType, + extraData: {...data}, + )); + + final res = await client.updateChannel(channelId, channelType, data); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData['name'], 'test-channel'); + + verify(() => api.channel.updateChannel(channelId, channelType, data)) + .called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.updateChannelPartial`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + const unset = ['tag', 'last_name']; + + when(() => api.channel.updateChannelPartial(channelId, channelType, + set: set, unset: unset)) + .thenAnswer((invocation) async => PartialUpdateChannelResponse() + ..channel = ChannelModel( + id: channelId, + type: channelType, + extraData: {...set}, + )); + + final res = await client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + expect(res, isNotNull); + expect(res.channel.cid, '$channelType:$channelId'); + expect(res.channel.extraData, set); + + verify(() => api.channel.updateChannelPartial(channelId, channelType, + set: set, unset: unset)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.addDevice`', () async { + const id = 'test-device-id'; + const provider = PushProvider.firebase; + + when(() => api.device.addDevice(id, provider)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.addDevice(id, provider); + expect(res, isNotNull); + + verify(() => api.device.addDevice(id, provider)).called(1); + verifyNoMoreInteractions(api.device); + }); + + test('`.getDevices`', () async { + final devices = List.generate( + 3, + (index) => Device( + id: 'test-device-id-$index', + pushProvider: PushProvider.firebase.name, + ), + ); + + when(() => api.device.getDevices()) + .thenAnswer((_) async => ListDevicesResponse()..devices = devices); + + final res = await client.getDevices(); + expect(res, isNotNull); + expect(res.devices.length, devices.length); + + verify(() => api.device.getDevices()).called(1); + verifyNoMoreInteractions(api.device); + }); + + test('`.removeDevice`', () async { + const deviceId = 'test-device-id'; + + when(() => api.device.removeDevice(deviceId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.removeDevice(deviceId); + expect(res, isNotNull); + + verify(() => api.device.removeDevice(deviceId)).called(1); + verifyNoMoreInteractions(api.device); + }); + + test('`.devToken`', () async { + const userId = 'test-user-id'; + + final token = client.devToken(userId); + + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.authType, AuthType.jwt); + }); + + group('`.channel`', () { + test('should return back a new channel instance', () { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + + final channel = client.channel( + channelType, + id: channelId, + extraData: channelData, + ); + + expect(channel, isNotNull); + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + }); + + test('should return back in memory channel instance if available', + () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channel = client.channel( + channelType, + id: channelId, + extraData: channelData, + ); + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + expectLater( + client.state.channelsStream.skip(1), + emitsInOrder([ + {channelCid: isCorrectChannelFor(channelState)} + ]), + ); + + await channel.watch(); + + final newChannel = client.channel(channelType, id: channelId); + expect(newChannel, channel); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + }); + }); + + test('`.createChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid, extraData: channelData), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + final res = await client.createChannel( + channelType, + channelId: channelId, + channelData: channelData, + ); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.watchChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid, extraData: channelData), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + final res = await client.watchChannel( + channelType, + channelId: channelId, + channelData: channelData, + ); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.queryChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelData = {'name': 'test-channel-name'}; + const channelCid = '$channelType:$channelId'; + + final channelState = ChannelState( + channel: ChannelModel(cid: channelCid, extraData: channelData), + ); + + when(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).thenAnswer((_) async => channelState); + + final res = await client.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + ); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + final channel = res.channel!; + expect(channel.type, channelType); + expect(channel.id, channelId); + expect(channel.cid, '$channelType:$channelId'); + expect(channel.extraData, channelData); + + verify(() => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.queryMembers`', () async { + const channelType = 'test-channel-type'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + when(() => api.general.queryMembers(channelType)).thenAnswer( + (_) async => QueryMembersResponse()..members = members, + ); + + final res = await client.queryMembers(channelType); + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify(() => api.general.queryMembers(channelType)).called(1); + verifyNoMoreInteractions(api.general); + }); + + test('`.hideChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.hideChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.hideChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.hideChannel(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.showChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.showChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.showChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.showChannel(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.deleteChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.deleteChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.deleteChannel(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.truncateChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.truncateChannel(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.truncateChannel(channelId, channelType); + + expect(res, isNotNull); + + verify( + () => api.channel.truncateChannel(channelId, channelType), + ).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.muteChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.moderation.muteChannel(channelCid)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.muteChannel(channelCid); + + expect(res, isNotNull); + + verify(() => api.moderation.muteChannel(channelCid)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unmuteChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.moderation.unmuteChannel(channelCid)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unmuteChannel(channelCid); + + expect(res, isNotNull); + + verify(() => api.moderation.unmuteChannel(channelCid)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.acceptChannelInvite`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.channel.acceptChannelInvite(channelId, channelType)) + .thenAnswer((_) async => + AcceptInviteResponse()..channel = ChannelModel(cid: channelCid)); + + final res = await client.acceptChannelInvite(channelId, channelType); + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + + verify(() => api.channel.acceptChannelInvite(channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.rejectChannelInvite`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + when(() => api.channel.rejectChannelInvite(channelId, channelType)) + .thenAnswer((_) async => + RejectInviteResponse()..channel = ChannelModel(cid: channelCid)); + + final res = await client.rejectChannelInvite(channelId, channelType); + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + + verify(() => api.channel.rejectChannelInvite(channelId, channelType)) + .called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.addChannelMembers`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + final memberIds = members.map((e) => e.userId!).toList(growable: false); + + when(() => api.channel.addMembers(channelId, channelType, memberIds)) + .thenAnswer((_) async => AddMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members); + + final res = await client.addChannelMembers( + channelId, + channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + expect(res.members.length, memberIds.length); + + verify( + () => api.channel.addMembers(channelId, channelType, memberIds), + ).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.removeChannelMembers`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + final memberIds = members.map((e) => e.userId!).toList(growable: false); + + when(() => api.channel.removeMembers(channelId, channelType, memberIds)) + .thenAnswer((_) async => RemoveMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members); + + final res = await client.removeChannelMembers( + channelId, + channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + expect(res.members.length, memberIds.length); + + verify( + () => api.channel.removeMembers(channelId, channelType, memberIds), + ).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.inviteChannelMembers`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const channelCid = '$channelType:$channelId'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + final memberIds = members.map((e) => e.userId!).toList(growable: false); + + when(() => api.channel + .inviteChannelMembers(channelId, channelType, memberIds)) + .thenAnswer((_) async => InviteMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members); + + final res = await client.inviteChannelMembers( + channelId, + channelType, + memberIds, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelCid); + expect(res.members.length, memberIds.length); + + verify(() => api.channel + .inviteChannelMembers(channelId, channelType, memberIds)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.stopChannelWatching`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.stopWatching(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.stopChannelWatching(channelId, channelType); + expect(res, isNotNull); + + verify(() => api.channel.stopWatching(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendAction`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const messageId = 'test-message-id'; + const formData = {'key': 'value'}; + + when(() => api.message + .sendAction(channelId, channelType, messageId, formData)) + .thenAnswer((_) async => SendActionResponse()); + + final res = await client.sendAction( + channelId, + channelType, + messageId, + formData, + ); + + expect(res, isNotNull); + + verify(() => api.message + .sendAction(channelId, channelType, messageId, formData)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.markChannelRead`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.markRead(channelId, channelType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.markChannelRead(channelId, channelType); + + expect(res, isNotNull); + + verify(() => api.channel.markRead(channelId, channelType)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.updateUser`', () async { + final user = User( + id: 'test-user-id', + extraData: const {'name': 'test-user'}, + ); + + when(() => api.user.updateUsers([user])).thenAnswer( + (_) async => UpdateUsersResponse()..users = {user.id: user}); + + final res = await client.updateUser(user); + + expect(res, isNotNull); + expect(res.users, {user.id: user}); + + verify(() => api.user.updateUsers([user])).called(1); + verifyNoMoreInteractions(api.user); + }); + + test('`.banUser`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.banUser(userId, options: any(named: 'options'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.banUser(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.banUser(userId, options: any(named: 'options')), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unbanUser`', () async { + const userId = 'test-user-id'; + + when(() => + api.moderation.unbanUser(userId, options: any(named: 'options'))) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unbanUser(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.unbanUser(userId, options: any(named: 'options')), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.shadowBan`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.banUser(userId, options: {'shadow': true})) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.shadowBan(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.banUser(userId, options: {'shadow': true}), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.removeShadowBan`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.unbanUser(userId, options: {'shadow': true})) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.removeShadowBan(userId); + + expect(res, isNotNull); + + verify( + () => api.moderation.unbanUser(userId, options: {'shadow': true}), + ).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.muteUser`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.muteUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.muteUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.muteUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unmuteUser`', () async { + const userId = 'test-user-id'; + + when(() => api.moderation.unmuteUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unmuteUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.unmuteUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.flagMessage`', () async { + const messageId = 'test-message-id'; + + when(() => api.moderation.flagMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.flagMessage(messageId); + + expect(res, isNotNull); + + verify(() => api.moderation.flagMessage(messageId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unflagMessage`', () async { + const messageId = 'test-message-id'; + + when(() => api.moderation.unflagMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unflagMessage(messageId); + + expect(res, isNotNull); + + verify(() => api.moderation.unflagMessage(messageId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.flagUser`', () async { + const userId = 'test-message-id'; + + when(() => api.moderation.flagUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.flagUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.flagUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.unflagUser`', () async { + const userId = 'test-message-id'; + + when(() => api.moderation.unflagUser(userId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.unflagUser(userId); + + expect(res, isNotNull); + + verify(() => api.moderation.unflagUser(userId)).called(1); + verifyNoMoreInteractions(api.moderation); + }); + + test('`.markAllRead`', () async { + when(() => api.channel.markAllRead()) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.markAllRead(); + expect(res, isNotNull); + + verify(() => api.channel.markAllRead()).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendEvent`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final event = Event(type: EventType.any); + + when( + () => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await client.sendEvent(channelId, channelType, event); + expect(res, isNotNull); + + verify(() => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendReaction`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + + when(() => api.message.sendReaction(messageId, reactionType)) + .thenAnswer((_) async => SendReactionResponse() + ..message = Message(id: messageId) + ..reaction = Reaction(type: reactionType, messageId: messageId)); + + final res = await client.sendReaction(messageId, reactionType); + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.type, reactionType); + expect(res.reaction.messageId, messageId); + + verify(() => api.message.sendReaction(messageId, reactionType)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.deleteReaction`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + + when(() => api.message.deleteReaction(messageId, reactionType)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteReaction(messageId, reactionType); + expect(res, isNotNull); + + verify( + () => api.message.deleteReaction(messageId, reactionType), + ).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.sendMessage`', () async { + final message = Message(id: 'test-message-id'); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + when(() => api.message.sendMessage( + channelId, channelType, any(that: isSameMessageAs(message)))) + .thenAnswer((_) async => SendMessageResponse()..message = message); + + final res = await client.sendMessage(message, channelId, channelType); + expect(res, isNotNull); + expect(res.message, isSameMessageAs(message)); + + verify(() => api.message.sendMessage( + channelId, + channelType, + any(that: isSameMessageAs(message)), + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getReplies`', () async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + when(() => api.message.getReplies(parentId)) + .thenAnswer((_) async => QueryRepliesResponse()..messages = messages); + + final res = await client.getReplies(parentId); + expect(res, isNotNull); + expect(res.messages.length, messages.length); + + verify(() => api.message.getReplies(parentId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getReactions`', () async { + const messageId = 'test-parent-id'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reactions-type-$index', + messageId: messageId, + ), + ); + + when(() => api.message.getReactions(messageId)).thenAnswer( + (_) async => QueryReactionsResponse()..reactions = reactions); + + final res = await client.getReactions(messageId); + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify(() => api.message.getReactions(messageId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.updateMessage`', () async { + final message = Message(id: 'test-message-id', text: 'Hello!'); + + when(() => api.message.updateMessage(any(that: isSameMessageAs(message)))) + .thenAnswer((_) async => UpdateMessageResponse()..message = message); + + final res = await client.updateMessage(message); + expect(res, isNotNull); + expect(res.message, isSameMessageAs(message)); + + verify( + () => api.message.updateMessage(any(that: isSameMessageAs(message))), + ).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.deleteMessage`', () async { + const messageId = 'test-message-id'; + + when(() => api.message.deleteMessage(messageId)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteMessage(messageId); + expect(res, isNotNull); + + verify(() => api.message.deleteMessage(messageId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getMessage`', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + when(() => api.message.getMessage(messageId)) + .thenAnswer((_) async => GetMessageResponse()..message = message); + + final res = await client.getMessage(messageId); + expect(res, isNotNull); + expect(res.message.id, messageId); + + verify(() => api.message.getMessage(messageId)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.getMessagesById`', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageIds = ['test-message-id']; + + final messages = messageIds.map((id) => Message(id: id)).toList(); + + when( + () => api.message.getMessagesById(channelId, channelType, messageIds), + ).thenAnswer((_) async => GetMessagesByIdResponse()..messages = messages); + + final res = await client.getMessagesById( + channelId, + channelType, + messageIds, + ); + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + verify( + () => api.message.getMessagesById(channelId, channelType, messageIds), + ).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.translateMessage`', () async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'ā¤¨ā¤Žā¤¸āĨā¤¤āĨ‡'; + final translatedMessage = TranslatedMessage(const { + language: translatedMessageText, + }); + + when(() => api.message.translateMessage(messageId, language)).thenAnswer( + (_) async => TranslateMessageResponse()..message = translatedMessage, + ); + + final res = await client.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + verify(() => api.message.translateMessage(messageId, language)).called(1); + verifyNoMoreInteractions(api.message); + }); + + test('`.partialUpdateMessage`', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = UpdateMessageResponse() + ..message = message.copyWith(text: set['text'], pinExpires: null); + + when(() => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + )).thenAnswer((_) async => updateMessageResponse); + + final res = await client.partialUpdateMessage( + messageId, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); + + verify(() => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + group('`.pinMessage`', () { + test('should work fine without passing timeoutOrExpirationDate', + () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + + when(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: null, + status: MessageSendingStatus.sent, + )); + + final res = await client.pinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + verifyNoMoreInteractions(api.message); + }); + + test( + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', + () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + const timeoutOrExpirationDate = 300; // 300 seconds + + when(() => api.message.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: DateTime.now().add( + const Duration(seconds: timeoutOrExpirationDate), + ), + status: MessageSendingStatus.sent, + )); + + final res = await client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + verifyNoMoreInteractions(api.message); + }, + ); + + test( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId); + final timeoutOrExpirationDate = + DateTime.now().add(const Duration(days: 3)); // 3 days + + when(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + status: MessageSendingStatus.sent, + )); + + final res = await client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + )).called(1); + verifyNoMoreInteractions(api.message); + }, + ); + + test( + 'should throw if invalid timeoutOrExpirationDate is passed', + () async { + const messageId = 'test-message-id'; + const timeoutOrExpirationDate = 'invalid-value'; + + try { + await client.pinMessage( + messageId, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + }); + + test('`.unpinMessage`', () async { + const messageId = 'test-message-id'; + final message = Message(id: messageId, pinned: true); + + when(() => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + )).thenAnswer((_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: false, + status: MessageSendingStatus.sent, + )); + + final res = await client.unpinMessage(messageId); + + expect(res, isNotNull); + expect(res.message.pinned, isFalse); + + verify(() => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + )).called(1); + verifyNoMoreInteractions(api.message); + }); + }); +} diff --git a/packages/stream_chat/test/src/api/retry_queue_test.dart b/packages/stream_chat/test/src/api/retry_queue_test.dart new file mode 100644 index 00000000..084b82ec --- /dev/null +++ b/packages/stream_chat/test/src/api/retry_queue_test.dart @@ -0,0 +1,74 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/retry_policy.dart'; +import 'package:stream_chat/src/client/retry_queue.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:test/scaffolding.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + late final channel = MockRetryQueueChannel(); + late final logger = MockLogger(); + late RetryQueue retryQueue; + + setUpAll(() { + final retryPolicy = RetryPolicy( + shouldRetry: (_, attempt, __) => attempt < 5, + retryTimeout: (_, attempt, __) => Duration(seconds: attempt), + ); + when(() => channel.client.retryPolicy).thenReturn(retryPolicy); + + when(() => channel.client.on(EventType.connectionRecovered)).thenAnswer( + (_) => Stream.value(Event( + type: EventType.connectionRecovered, + online: false, + )), + ); + + when(() => channel.on(any(), any(), any(), any())).thenAnswer( + (_) => Stream.value( + Event(type: EventType.any), + ), + ); + }); + + setUp(() { + retryQueue = RetryQueue(channel: channel, logger: logger); + }); + + tearDown(() { + retryQueue.dispose(); + }); + + group('`.add`', () { + test('should return if message list is empty', () { + expect(() => retryQueue.add([]), returnsNormally); + verifyNever(() => logger.info(any())); + }); + + test('should return if queue already contains the message', () { + final message = Message( + id: 'test-message-id', + text: 'Sample message test', + ); + retryQueue.add([message]); + expect(() => retryQueue.add([message]), returnsNormally); + // Called only for the first message + verify(() => logger.info('Adding 1 messages')).called(1); + }); + + test('`.add` should add failed request to the queue', () async { + final message = Message( + id: 'test-message-id', + text: 'Sample message test', + ); + retryQueue.add([message]); + expect(retryQueue.hasMessages, isTrue); + }); + }); + + // TODO: Add more tests once macbook is fixed :( +} diff --git a/packages/stream_chat/test/src/api/web_socket_stub_test.dart b/packages/stream_chat/test/src/api/web_socket_stub_test.dart deleted file mode 100644 index 7f5bf4b1..00000000 --- a/packages/stream_chat/test/src/api/web_socket_stub_test.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:test/test.dart'; -import 'package:stream_chat/src/api/web_socket_channel_stub.dart'; - -void main() { - test('src/api/web_socket_stub_test', () { - expect( - () => connectWebSocket('fakeurl'), - throwsA(isA()), - ); - }); -} diff --git a/packages/stream_chat/test/src/api/websocket_test.dart b/packages/stream_chat/test/src/api/websocket_test.dart deleted file mode 100644 index b9c897a6..00000000 --- a/packages/stream_chat/test/src/api/websocket_test.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'dart:async'; - -import 'package:logging/logging.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/api/connection_status.dart'; -import 'package:stream_chat/src/api/websocket.dart'; -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -class Functions { - WebSocketChannel connectFunc( - String? url, { - Iterable? protocols, - }) => - WebSocketChannel.connect(Uri()); - - void handleFunc(Event event) {} -} - -class MockFunctions extends Mock implements Functions {} - -class MockWSChannel extends Mock implements WebSocketChannel {} - -class MockWSSink extends Mock implements WebSocketSink {} - -class FakeEvent extends Fake implements Event {} - -void main() { - group('src/api/websocket', () { - setUpAll(() { - registerFallbackValue(FakeEvent()); - }); - - test('should connect with correct parameters', () async { - final connectFunc = MockFunctions().connectFunc; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: print, - connectFunc: connectFunc, - ); - final mockWSChannel = MockWSChannel(); - final streamController = StreamController.broadcast(); - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(() => mockWSChannel.stream).thenAnswer( - (_) => streamController.stream, - ); - - final timer = Timer.periodic( - const Duration(milliseconds: 100), - (_) => streamController.sink.add('{}'), - ); - - await ws.connect(); - - verify(() => connectFunc(computedUrl)).called(1); - expect(ws.connectionStatus, ConnectionStatus.connected); - - await streamController.close(); - timer.cancel(); - }); - }); - - test('should connect with correct parameters and handle events', () async { - final handleFunc = MockFunctions().handleFunc; - final connectFunc = MockFunctions().connectFunc; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - final mockWSChannel = MockWSChannel(); - final streamController = StreamController.broadcast(); - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(() => mockWSChannel.stream).thenAnswer((_) => streamController.stream); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(const Duration(milliseconds: 200)); - }).then((value) { - verify(() => connectFunc(computedUrl)).called(1); - verify(() => handleFunc(any())).called(greaterThan(0)); - - return streamController.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should close correctly the controller', () async { - final handleFunc = MockFunctions().handleFunc; - final connectFunc = MockFunctions().connectFunc; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - final mockWSChannel = MockWSChannel(); - final streamController = StreamController.broadcast(); - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - final mockWSSink = MockWSSink(); - when(() => mockWSChannel.sink).thenAnswer((_) => mockWSSink); - when(() => mockWSSink.close(any(), any())).thenAnswer((_) async => null); - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.stream).thenAnswer((_) => streamController.stream); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(const Duration(milliseconds: 200)); - }).then((value) { - verify(() => connectFunc(computedUrl)).called(1); - verify(() => handleFunc(any())).called(greaterThan(0)); - - return streamController.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should close correctly the controller while connecting', () async { - final handleFunc = MockFunctions().handleFunc; - - final connectFunc = MockFunctions().connectFunc; - - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - - final mockWSChannel = MockWSChannel(); - - final streamController = StreamController.broadcast(); - - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - final mockWSSink = MockWSSink(); - when(() => mockWSChannel.sink).thenAnswer((_) => mockWSSink); - when(() => mockWSSink.close(any(), any())).thenAnswer((_) async => null); - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.stream).thenAnswer((_) => streamController.stream); - - ws.connect(); - await ws.disconnect(); - streamController.add('{}'); - - verify(() => connectFunc(computedUrl)).called(1); - verifyNever(() => handleFunc(any())); - - addTearDown(streamController.close); - }); - - test('should run correctly health check', () async { - final handleFunc = MockFunctions().handleFunc; - final connectFunc = MockFunctions().connectFunc; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - final mockWSChannel = MockWSChannel(); - final mockWSSink = MockWSSink(); - final streamController = StreamController.broadcast(); - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.stream).thenAnswer((_) => streamController.stream); - when(() => mockWSChannel.sink).thenAnswer((_) => mockWSSink); - when(() => mockWSSink.close(any(), any())).thenAnswer((_) async => null); - - final timer = Timer.periodic( - const Duration(milliseconds: 1000), - (_) => streamController.sink.add('{}'), - ); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(const Duration(milliseconds: 200)); - }).then((value) async { - verify(() => mockWSSink.add("{'type': 'health.check'}")) - .called(greaterThan(0)); - - timer.cancel(); - await streamController.close(); - return mockWSSink.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should run correctly reconnection check', () async { - final handleFunc = MockFunctions().handleFunc; - final connectFunc = MockFunctions().connectFunc; - Logger.root.level = Level.ALL; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - reconnectionMonitorTimeout: 1, - ); - final mockWSChannel = MockWSChannel(); - final mockWSSink = MockWSSink(); - var streamController = StreamController.broadcast(); - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.stream).thenAnswer((_) => streamController.stream); - when(() => mockWSChannel.sink).thenAnswer((_) => mockWSSink); - when(() => mockWSSink.close(any(), any())).thenAnswer((_) async => null); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - streamController.close(); - streamController = StreamController.broadcast(); - streamController.sink.add('{}'); - return Future.delayed(const Duration(milliseconds: 200)); - }).then((value) async { - verify(() => mockWSSink.add("{'type': 'health.check'}")) - .called(greaterThan(0)); - - verify(() => connectFunc(computedUrl)).called(2); - - await streamController.close(); - return mockWSSink.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should close correctly the controller', () async { - final handleFunc = MockFunctions().handleFunc; - final connectFunc = MockFunctions().connectFunc; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: handleFunc, - connectFunc: connectFunc, - ); - final mockWSChannel = MockWSChannel(); - final mockWSSink = MockWSSink(); - final streamController = StreamController.broadcast(); - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.stream).thenAnswer((_) => streamController.stream); - when(() => mockWSChannel.sink).thenAnswer((_) => mockWSSink); - when(() => mockWSSink.close(any(), any())).thenAnswer((_) async => null); - - final connect = ws.connect().then((_) { - streamController.sink.add('{}'); - return Future.delayed(const Duration(milliseconds: 200)); - }).then((value) async { - await ws.disconnect(); - verify(mockWSSink.close).called(greaterThan(0)); - - await streamController.close(); - await mockWSSink.close(); - }); - - streamController.sink.add('{}'); - - return connect; - }); - - test('should throw an error', () async { - final connectFunc = MockFunctions().connectFunc; - final ws = WebSocket( - baseUrl: 'baseurl', - user: User(id: 'testid'), - logger: Logger('ws'), - connectParams: {'test': 'true'}, - connectPayload: {'payload': 'test'}, - handler: print, - connectFunc: connectFunc, - ); - final mockWSChannel = MockWSChannel(); - final streamController = StreamController.broadcast(); - const computedUrl = - 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; - - when(() => connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); - when(() => mockWSChannel.sink).thenAnswer((_) => MockWSSink()); - when(() => mockWSChannel.stream).thenAnswer((_) => streamController.stream); - - Future.delayed( - const Duration(milliseconds: 1000), - () => streamController.sink.addError('test error'), - ); - - try { - expect(await ws.connect(), throwsA(isA())); - } catch (e) { - verify(() => connectFunc(computedUrl)).called(greaterThanOrEqualTo(1)); - streamController.close(); - } - }); -} diff --git a/packages/stream_chat/test/src/client_test.dart b/packages/stream_chat/test/src/client_test.dart deleted file mode 100644 index 2450de7c..00000000 --- a/packages/stream_chat/test/src/client_test.dart +++ /dev/null @@ -1,1239 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:dio/native_imp.dart'; -import 'package:logging/logging.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/api/requests.dart'; -import 'package:stream_chat/src/client.dart'; -import 'package:stream_chat/src/exceptions.dart'; -import 'package:stream_chat/src/models/channel_model.dart'; -import 'package:stream_chat/src/models/filter.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:test/test.dart'; - -class MockDio extends Mock implements DioForNative {} - -class FakeRequestOptions extends Fake implements RequestOptions {} - -class MockHttpClientAdapter extends Mock implements HttpClientAdapter {} - -class Functions { - Future tokenProvider(String userId) async => ''; -} - -class MockFunctions extends Mock implements Functions {} - -void main() { - group('src/client', () { - setUpAll(() { - registerFallbackValue(FakeRequestOptions()); - registerFallbackValue>(const Stream.empty()); - registerFallbackValue>(Future.value()); - }); - - group('constructor', () { - final log = []; - - dynamic overridePrint(testFn()) => () { - log.clear(); - final spec = ZoneSpecification(print: (_, __, ___, String msg) { - // Add to log instead of printing to stdout - log.add(msg); - }); - return Zone.current.fork(specification: spec).run(testFn); - }; - - tearDown(log.clear); - - test('should create the object correctly', () { - final client = StreamChatClient('api-key'); - - expect(client.baseURL, 'chat-us-east-1.stream-io-api.com'); - expect(client.apiKey, 'api-key'); - expect(client.logLevel, Level.WARNING); - expect(client.httpClient.options.connectTimeout, 6000); - expect(client.httpClient.options.receiveTimeout, 6000); - }); - - test('should create the object correctly', overridePrint(() { - void logHandler(LogRecord record) { - print(record.message); - } - - final client = StreamChatClient( - 'api-key', - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 12), - logLevel: Level.INFO, - baseURL: 'test.com', - logHandlerFunction: logHandler, - ); - - expect(client.baseURL, 'test.com'); - expect(client.apiKey, 'api-key'); - expect(Logger.root.level, Level.INFO); - expect(client.httpClient.options.connectTimeout, 10000); - expect(client.httpClient.options.receiveTimeout, 12000); - - client.logger.warning('test'); - client.logger.config('test config'); - - expect( - [log[log.length - 2], log[log.length - 1]], - ['instantiating new client', 'test'], - ); - })); - - test('Channel', () { - final client = StreamChatClient('test'); - final data = {'test': 1}; - final channelClient = client.channel('type', id: 'id', extraData: data); - expect(channelClient.type, 'type'); - expect(channelClient.id, 'id'); - }); - }); - - group('queryChannelsOnline', () { - test('should pass right default parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': null, - 'sort': null, - 'state': true, - 'watch': true, - 'presence': false, - 'limit': 10, - 'offset': 0, - }), - }; - - when( - () => mockDio.get('/channels', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.queryChannelsOnline(waitForConnect: false); - - verify(() => - mockDio.get('/channels', queryParameters: queryParams)) - .called(1); - }); - - test('should pass right parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final queryFilter = Filter.in_('id', const ['test']); - final sortOptions = >[]; - final options = {'state': false, 'watch': false, 'presence': true}; - const paginationParams = PaginationParams(offset: 2); - - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': queryFilter, - 'sort': sortOptions, - } - ..addAll(options) - ..addAll(paginationParams - .toJson() - .map((key, value) => MapEntry(key, value as Object)))), - }; - - when( - () => mockDio.get('/channels', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{"channels":[]}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.queryChannelsOnline( - filter: queryFilter, - sort: sortOptions, - options: options, - paginationParams: paginationParams, - waitForConnect: false, - ); - - verify(() => - mockDio.get('/channels', queryParameters: queryParams)) - .called(1); - }); - }); - - group('search', () { - test('should pass right default parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final filter = Filter.in_('cid', const ['messaging:testId']); - const query = 'hello'; - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': filter, - 'query': query, - }), - }; - - when( - () => mockDio.get('/search', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.search(filter, query: query); - - verify(() => - mockDio.get('/search', queryParameters: queryParams)) - .called(1); - }); - - test('should pass right parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final filters = Filter.in_('id', const ['test']); - const sortOptions = [SortOption('name')]; - const query = 'query'; - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': filters, - 'query': query, - 'sort': sortOptions, - 'limit': 10, - 'offset': 0, - }), - }; - - when( - () => mockDio.get('/search', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.search( - filters, - sort: sortOptions, - query: query, - paginationParams: const PaginationParams(), - ); - - verify( - () => mockDio.get('/search', queryParameters: queryParams), - ).called(1); - }); - }); - - group('devices', () { - test('addDevice', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post('/devices', data: { - 'id': 'test-id', - 'push_provider': 'firebase', - }), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.addDevice('test-id', PushProvider.firebase); - - verify( - () => mockDio.post( - '/devices', - data: {'id': 'test-id', 'push_provider': 'firebase'}, - ), - ).called(1); - }); - - test('getDevices', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when(() => mockDio.get('/devices')).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.getDevices(); - - verify(() => mockDio.get('/devices')).called(1); - }); - - test('removeDevice', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.delete( - '/devices', - queryParameters: {'id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.removeDevice('test-id'); - - verify( - () => mockDio.delete( - '/devices', - queryParameters: {'id': 'test-id'}, - ), - ).called(1); - }); - }); - - test('devToken', () { - final client = StreamChatClient('api-key'); - final token = client.devToken('test'); - - expect( - token, - '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGVzdCJ9.devtoken''', - ); - }); - - group('queryUsers', () { - test('should pass right default parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': null, - 'sort': null, - 'presence': false, - }), - }; - - when( - () => mockDio.get('/users', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{"users":[]}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.queryUsers(); - - verify( - () => mockDio.get( - '/users', - queryParameters: queryParams, - ), - ).called(1); - }); - - test('should pass right parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final queryFilter = Filter.in_('id', const ['test']); - const sortOptions = []; - final options = {'presence': true}; - final queryParams = { - 'payload': json.encode({ - 'filter_conditions': queryFilter, - 'sort': sortOptions, - }..addAll(options)), - }; - - when( - () => mockDio.get('/users', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{"users":[]}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.queryUsers( - filter: queryFilter, - sort: sortOptions, - options: options, - ); - - verify(() => - mockDio.get('/users', queryParameters: queryParams)) - .called(1); - }); - }); - - group('user', () { - test('connectUser should throw exception', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post( - '/moderation/flag', - data: {'target_user_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.flagUser('test-id'); - - verify(() => mockDio.post('/moderation/flag', - data: {'target_user_id': 'test-id'})).called(1); - }); - - test('flagUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - expect(() => client.connectUserWithProvider(User(id: 'test-id')), - throwsA(isA())); - }); - - test('unflagUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post( - '/moderation/unflag', - data: {'target_user_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.unflagUser('test-id'); - - verify(() => mockDio.post('/moderation/unflag', - data: {'target_user_id': 'test-id'})).called(1); - }); - - test('updateUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final user = User(id: 'test-id'); - final data = { - 'users': {user.id: user.toJson()}, - }; - - when(() => mockDio.post('/users', data: data)).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.updateUser(user); - - verify(() => mockDio.post('/users', data: data)).called(1); - }); - - test('updateUsers', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final user = User(id: 'test-id'); - final user2 = User(id: 'test-id2'); - - final data = { - 'users': { - user.id: user.toJson(), - user2.id: user2.toJson(), - }, - }; - - when(() => mockDio.post('/users', data: data)).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.updateUsers([user, user2]); - - verify(() => mockDio.post('/users', data: data)).called(1); - }); - - test('banUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post( - '/moderation/ban', - data: {'test': true, 'target_user_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.banUser('test-id', {'test': true}); - - verify(() => mockDio.post('/moderation/ban', - data: {'test': true, 'target_user_id': 'test-id'})).called(1); - }); - - test('unbanUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.delete( - '/moderation/ban', - queryParameters: {'test': true, 'target_user_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.unbanUser('test-id', {'test': true}); - - verify(() => mockDio.delete('/moderation/ban', - queryParameters: {'test': true, 'target_user_id': 'test-id'})) - .called(1); - }); - - test('muteUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post( - '/moderation/mute', - data: {'target_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.muteUser('test-id'); - - verify(() => mockDio.post('/moderation/mute', - data: {'target_id': 'test-id'})).called(1); - }); - - test('unmuteUser', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post( - '/moderation/unmute', - data: {'target_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.unmuteUser('test-id'); - - verify(() => mockDio.post('/moderation/unmute', - data: {'target_id': 'test-id'})).called(1); - }); - }); - - group('message', () { - test('flagMessage', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post( - '/moderation/flag', - data: {'target_message_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.flagMessage('test-id'); - - verify(() => mockDio.post('/moderation/flag', - data: {'target_message_id': 'test-id'})).called(1); - }); - - test('unflagMessage', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when( - () => mockDio.post( - '/moderation/unflag', - data: {'target_message_id': 'test-id'}, - ), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.unflagMessage('test-id'); - - verify(() => mockDio.post('/moderation/unflag', - data: {'target_message_id': 'test-id'})).called(1); - }); - - test('updateMessage', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final message = Message( - id: 'test', - ); - - when( - () => mockDio.post( - '/messages/${message.id}', - data: {'message': anything}, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.updateMessage(message); - - verify(() => mockDio.post('/messages/${message.id}', - data: {'message': anything})).called(1); - }); - - test('partiallyUpdateMessage', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final message = Message( - id: 'test', - text: 'demo', - ); - - when( - () => mockDio.put( - '/messages/${message.id}', - data: {'set': anything}, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.partiallyUpdateMessage(message.id, { - 'set': {'text': message.text} - }); - - verify(() => mockDio.put('/messages/${message.id}', - data: {'set': anything})).called(1); - }); - - test('deleteMessage', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - const messageId = 'test'; - - when(() => mockDio.delete('/messages/$messageId')).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.deleteMessage(Message(id: messageId)); - - verify(() => mockDio.delete('/messages/$messageId')).called(1); - }); - - test('getMessage', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - const messageId = 'test'; - - when(() => mockDio.get('/messages/$messageId')).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': Message(id: messageId)}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.getMessage(messageId); - - verify(() => mockDio.get('/messages/$messageId')).called(1); - }); - - test('markAllRead', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when(() => mockDio.post('/channels/read')).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.markAllRead(); - - verify(() => mockDio.post('/channels/read')).called(1); - }); - }); - - group('api methods', () { - group('get', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final queryParams = {'test': 1}; - - when( - () => mockDio.get('/test', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.get('/test', queryParameters: queryParams); - - verify(() => - mockDio.get('/test', queryParameters: queryParams)) - .called(1); - }); - - test('should catch the error', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when(() => mockDio.get(any())).thenThrow( - DioError( - type: DioErrorType.response, - response: Response( - data: 'test error', - statusCode: 400, - requestOptions: FakeRequestOptions(), - ), - requestOptions: FakeRequestOptions(), - ), - ); - - expect( - client.get('/test'), - throwsA(ApiError('test error', 400)), - ); - }); - }); - - group('post', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final data = {'test': 1}; - - when(() => mockDio.post('/test', data: data)).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.post('/test', data: data); - - verify(() => mockDio.post('/test', data: data)).called(1); - }); - - test('should catch the error', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - when(() => mockDio.post(any())).thenThrow( - DioError( - type: DioErrorType.response, - response: Response( - data: 'test error', - statusCode: 400, - requestOptions: FakeRequestOptions(), - ), - requestOptions: FakeRequestOptions(), - ), - ); - - expect(client.post('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('put', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - final data = {'test': 1}; - - when(() => mockDio.put('/test', data: data)).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.put('/test', data: data); - - verify(() => mockDio.put('/test', data: data)).called(1); - }); - - test('should catch the error', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when(() => mockDio.put(any())).thenThrow( - DioError( - type: DioErrorType.response, - response: Response( - data: 'test error', - statusCode: 400, - requestOptions: FakeRequestOptions(), - ), - requestOptions: FakeRequestOptions(), - ), - ); - - expect(client.put('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('patch', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - final data = { - 'test': 1, - }; - - when(() => mockDio.patch('/test', data: data)).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.patch('/test', data: data); - - verify(() => mockDio.patch('/test', data: data)).called(1); - }); - - test('should catch the error', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when(() => mockDio.patch(any())).thenThrow( - DioError( - type: DioErrorType.response, - response: Response( - data: 'test error', - statusCode: 400, - requestOptions: FakeRequestOptions(), - ), - requestOptions: FakeRequestOptions(), - ), - ); - - expect(client.patch('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('delete', () { - test('should put the correct parameters', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final queryParams = { - 'test': 1, - }; - - when( - () => mockDio.delete('/test', queryParameters: queryParams), - ).thenAnswer( - (_) async => Response( - data: '{}', - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.delete('/test', queryParameters: queryParams); - - verify(() => - mockDio.delete('/test', queryParameters: queryParams)) - .called(1); - }); - - test('should catch the error', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient('api-key', httpClient: mockDio); - - when(() => mockDio.delete(any())).thenThrow( - DioError( - type: DioErrorType.response, - response: Response( - data: 'test error', - statusCode: 400, - requestOptions: FakeRequestOptions(), - ), - requestOptions: FakeRequestOptions(), - ), - ); - - expect(client.delete('/test'), throwsA(ApiError('test error', 400))); - }); - }); - - group('pin message', () { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - test('should throw argument error', () { - final message = Message(text: 'Hello'); - expect( - () => client.pinMessage(message, 'InvalidType'), - throwsArgumentError, - ); - }); - - test('should complete successfully', () async { - const timeout = 30; - final message = Message(text: 'Hello'); - - when( - () => mockDio.put( - '/messages/${message.id}', - data: anything, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.pinMessage(message, timeout); - - verify(() => mockDio.put('/messages/${message.id}', - data: {'set': anything})).called(1); - }); - - test('should complete successfully with a null value', () async { - final message = Message(text: 'Hello'); - - when( - () => mockDio.put( - '/messages/${message.id}', - data: anything, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.pinMessage(message); - - verify(() => mockDio.put('/messages/${message.id}', - data: {'set': anything})).called(1); - }); - - test('should unpin message successfully', () async { - final message = Message(text: 'Hello'); - - when( - () => mockDio.put( - '/messages/${message.id}', - data: anything, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'message': message}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await client.unpinMessage(message); - - verify(() => mockDio.put('/messages/${message.id}', - data: anything)).called(1); - }); - }); - }); - - group('channel', () { - test('should update channel', () async { - final mockDio = MockDio(); - - when(() => mockDio.options).thenReturn(BaseOptions()); - when(() => mockDio.interceptors).thenReturn(Interceptors()); - - final client = StreamChatClient( - 'api-key', - httpClient: mockDio, - ); - - final channelClient = client.channel( - 'type', - id: 'id', - extraData: {'name': 'init'}, - ); - - const update = { - 'set': {'name': 'demo'} - }; - - when( - () => mockDio.patch( - '/channels/${channelClient.type}/${channelClient.id}', - data: update, - ), - ).thenAnswer( - (_) async => Response( - data: jsonEncode({'channel': ChannelModel(cid: 'messaging:test')}), - statusCode: 200, - requestOptions: FakeRequestOptions(), - ), - ); - - await channelClient.updatePartial(update); - verify( - () => mockDio.patch( - '/channels/${channelClient.type}/${channelClient.id}', - data: update, - ), - ).called(1); - }); - }); - }); -} diff --git a/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart new file mode 100644 index 00000000..27f4ce6a --- /dev/null +++ b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart @@ -0,0 +1,136 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/models/attachment_file.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../matchers.dart'; +import '../../mocks.dart'; +import '../../utils.dart'; + +void main() { + late final client = MockHttpClient(); + late StreamAttachmentFileUploader fileUploader; + + setUp(() { + fileUploader = StreamAttachmentFileUploader(client); + registerFallbackValue(FakeMultiPartFile()); + }); + + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + test('sendImage', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + const path = '/channels/$channelType/$channelId/image'; + final file = assetFile('test_image.jpeg'); + final attachmentFile = AttachmentFile( + size: 333, + path: file.path, + bytes: file.readAsBytesSync(), + ); + final multipartFile = await attachmentFile.toMultipartFile(); + + when(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).thenAnswer((_) async => successResponse(path, data: { + 'file': 'test-file-url', + })); + + final res = await fileUploader.sendImage( + attachmentFile, + channelId, + channelType, + ); + + expect(res, isNotNull); + expect(res.file, isNotNull); + expect(res.file, isNotEmpty); + + verify(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendFile', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + const path = '/channels/$channelType/$channelId/file'; + final file = assetFile('example.pdf'); + final attachmentFile = AttachmentFile( + size: 333, + path: file.path, + bytes: file.readAsBytesSync(), + ); + final multipartFile = await attachmentFile.toMultipartFile(); + + when(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).thenAnswer((_) async => successResponse(path, data: { + 'file': 'test-file-url', + })); + + final res = await fileUploader.sendFile( + attachmentFile, + channelId, + channelType, + ); + + expect(res, isNotNull); + expect(res.file, isNotNull); + expect(res.file, isNotEmpty); + + verify(() => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + )).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteImage', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const path = '/channels/$channelType/$channelId/image'; + + const url = 'test-image-url'; + + when(() => client.delete(path, queryParameters: {'url': url})).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await fileUploader.deleteImage(url, channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.delete(path, queryParameters: {'url': url})).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteFile', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const path = '/channels/$channelType/$channelId/file'; + + const url = 'test-file-url'; + + when(() => client.delete(path, queryParameters: {'url': url})).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await fileUploader.deleteFile(url, channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.delete(path, queryParameters: {'url': url})).called(1); + verifyNoMoreInteractions(client); + }); +} 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 new file mode 100644 index 00000000..ab530ff1 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -0,0 +1,608 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + String _getChannelUrl(String channelId, String channelType) => + '/channels/$channelType/$channelId'; + + ChannelState _generateChannelState( + String channelId, + String channelType, + ) { + final channel = ChannelModel(id: channelId, type: channelType); + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + text: 'test-message-text-$index', + ), + ); + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + final reads = List.generate( + 3, + (index) => Read( + lastRead: DateTime.now(), + user: User(id: 'test-user-id-$index'), + ), + ); + final watchers = List.generate( + 3, + (index) => User(id: 'test-user-id-$index'), + ); + final state = ChannelState( + channel: channel, + messages: messages, + pinnedMessages: messages, + members: members, + read: reads, + watchers: watchers, + watcherCount: watchers.length, + ); + return state; + } + + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late ChannelApi channelApi; + + setUp(() { + channelApi = ChannelApi(client); + }); + + test('queryChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const channelData = {'name': 'test-channel'}; + const messagePagination = PaginationParams(); + const membersPagination = PaginationParams(); + const watchersPagination = PaginationParams(); + + const channelPath = '/channels/$channelType/$channelId'; + const path = '$channelPath/query'; + + final channelState = _generateChannelState(channelId, channelType); + + final data = { + 'state': true, + 'watch': false, + 'presence': false, + 'data': channelData, + 'messages': messagePagination, + 'members': membersPagination, + 'watchers': watchersPagination, + }; + + when(() => client.post( + path, + data: data, + )).thenAnswer((_) async => successResponse( + path, + data: channelState.toJson(), + )); + + final res = await channelApi.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + messagesPagination: messagePagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + + expect(res, isNotNull); + expect(res.messages.length, channelState.messages.length); + expect(res.pinnedMessages.length, channelState.pinnedMessages.length); + expect(res.members.length, channelState.members.length); + expect(res.read.length, channelState.read.length); + expect(res.watchers.length, channelState.watchers.length); + expect(res.watcherCount, channelState.watcherCount); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('queryChannels', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final filter = Filter.in_('cid', const ['test-cid']); + const sort = [SortOption('test-field')]; + const memberLimit = 33; + const messageLimit = 33; + + const path = '/channels'; + + final channelState = _generateChannelState(channelId, channelType); + + final payload = jsonEncode({ + // default options + 'state': true, + 'watch': true, + 'presence': false, + + // passed options + 'sort': sort, + 'filter_conditions': filter, + 'member_limit': memberLimit, + 'message_limit': messageLimit, + + // pagination + ...const PaginationParams().toJson() + }); + + when(() => client.get( + path, + queryParameters: { + 'payload': payload, + }, + )).thenAnswer((_) async => successResponse( + path, + data: { + 'channels': [channelState.toJson()] + }, + )); + + final res = await channelApi.queryChannels( + filter: filter, + sort: sort, + memberLimit: memberLimit, + messageLimit: messageLimit, + ); + + expect(res, isNotNull); + expect(res.channels, isNotEmpty); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('markAllRead', () async { + const path = 'channels/read'; + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.markAllRead(); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const data = {'name': 'test-channel-name'}; + final message = Message(id: 'test-message-id', text: 'channel-updated'); + + final path = _getChannelUrl(channelId, channelType); + + final channelModel = ChannelModel( + id: channelId, + type: channelType, + extraData: data, + ); + + when(() => client.post( + path, + data: any( + named: 'data', + that: wrapMatcher((Map v) => + containsPair('data', data).matches(v, {}) && + contains('message').matches(v, {})), + ), + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.updateChannel( + channelId, + channelType, + data, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateChannelPartial', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + + const unset = ['tag', 'last_name']; + + final path = _getChannelUrl(channelId, channelType); + + final channelModel = ChannelModel( + id: channelId, + type: channelType, + extraData: set, + ); + + when( + () => client.patch(path, data: {'set': set, 'unset': unset}), + ).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + })); + + final res = await channelApi.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + + verify( + () => client.patch(path, data: {'set': set, 'unset': unset}), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('acceptChannelInvite', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'channel-accepted'); + + final channelModel = ChannelModel(id: channelId, type: channelType); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'accept_invite': true, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.acceptChannelInvite( + channelId, + channelType, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('rejectChannelInvite', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'channel-rejected'); + + final channelModel = ChannelModel(id: channelId, type: channelType); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'reject_invite': true, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.rejectChannelInvite( + channelId, + channelType, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('inviteChannelMembers', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const memberIds = ['test-member-id-1', 'test-member-id-2']; + final channelModel = ChannelModel(id: channelId, type: channelType); + final message = Message(id: 'test-message-id', text: 'members-invited'); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'invites': memberIds, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.inviteChannelMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('addMembers', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const memberIds = ['test-member-id-1', 'test-member-id-2']; + final channelModel = ChannelModel(id: channelId, type: channelType); + final message = Message(id: 'test-message-id', text: 'members-added'); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'add_members': memberIds, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.addMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('removeMembers', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const memberIds = ['test-member-id-1', 'test-member-id-2']; + final channelModel = ChannelModel(id: channelId, type: channelType); + final message = Message(id: 'test-message-id', text: 'members-removed'); + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.post( + path, + data: { + 'remove_members': memberIds, + 'message': message, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'channel': channelModel.toJson(), + 'message': message.toJson(), + })); + + final res = await channelApi.removeMembers( + channelId, + channelType, + memberIds, + message: message, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendEvent', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final event = Event(type: 'event.test'); + + final path = '${_getChannelUrl(channelId, channelType)}/event'; + + when(() => client.post(path, data: {'event': event})).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.sendEvent(channelId, channelType, event); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = _getChannelUrl(channelId, channelType); + + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.deleteChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.delete(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('truncateChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/truncate'; + + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.truncateChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('hideChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/hide'; + + when( + () => client.post( + path, + data: { + 'clear_history': false, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await channelApi.hideChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('hideChannel with clear_history: true', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/hide'; + + when( + () => client.post( + path, + data: { + 'clear_history': true, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await channelApi.hideChannel( + channelId, + channelType, + clearHistory: true, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('showChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/show'; + + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.showChannel(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('markRead', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageId = 'test-message-id'; + + final path = '${_getChannelUrl(channelId, channelType)}/read'; + + when(() => client.post( + path, + data: { + 'message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.markRead( + channelId, + channelType, + messageId: messageId, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('stopWatching', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/stop-watching'; + + when(() => client.post(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await channelApi.stopWatching(channelId, channelType); + + expect(res, isNotNull); + + verify(() => client.post(path)).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/device_api_test.dart b/packages/stream_chat/test/src/core/api/device_api_test.dart new file mode 100644 index 00000000..7d4f59fd --- /dev/null +++ b/packages/stream_chat/test/src/core/api/device_api_test.dart @@ -0,0 +1,94 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late DeviceApi deviceApi; + + setUp(() { + deviceApi = DeviceApi(client); + }); + + test('addDevice', () async { + const deviceId = 'test-device-id'; + const pushProvider = PushProvider.firebase; + + const path = '/devices'; + + when(() => client.post( + path, + data: { + 'id': deviceId, + 'push_provider': pushProvider.name, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await deviceApi.addDevice(deviceId, pushProvider); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('getDevices', () async { + const path = '/devices'; + + final devices = List.generate( + 3, + (index) => Device( + id: 'test-device-id-$index', + pushProvider: PushProvider.firebase.name, + ), + ); + + when(() => client.get(path)).thenAnswer( + (_) async => successResponse(path, data: { + 'devices': [...devices.map((it) => it.toJson())] + }), + ); + + final res = await deviceApi.getDevices(); + + expect(res, isNotNull); + expect(res.devices.length, devices.length); + + verify(() => client.get(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('removeDevice', () async { + const deviceId = 'test-device-id'; + + const path = '/devices'; + + when( + () => client.delete( + path, + queryParameters: {'id': deviceId}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await deviceApi.removeDevice(deviceId); + + expect(res, isNotNull); + + verify( + () => client.delete(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/general_api_test.dart b/packages/stream_chat/test/src/core/api/general_api_test.dart new file mode 100644 index 00000000..afb3f6aa --- /dev/null +++ b/packages/stream_chat/test/src/core/api/general_api_test.dart @@ -0,0 +1,266 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late GeneralApi generalApi; + + setUp(() { + generalApi = GeneralApi(client); + }); + + test('sync', () async { + const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; + final lastSyncAt = DateTime.now(); + + const path = '/sync'; + + final events = + List.generate(3, (index) => Event(type: 'test-event-type-$index')); + + final data = { + 'channel_cids': cids, + 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), + }; + + when(() => client.post( + path, + data: data, + )).thenAnswer((_) async => successResponse(path, data: { + 'events': [...events.map((it) => it.toJson())] + })); + + final res = await generalApi.sync(cids, lastSyncAt); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + group('searchMessages', () { + test( + 'should throw if `query` and `messageFilters` is not provided', + () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + try { + await generalApi.searchMessages(filter); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + 'should throw if `query` and `messageFilters` both are provided', + () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const query = 'test-query'; + final messageFilter = Filter.query('key', 'text'); + try { + await generalApi.searchMessages( + filter, + query: query, + messageFilters: messageFilter, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test('should run successfully with `query`', () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const query = 'test-query'; + const sort = [SortOption('test-field')]; + const pagination = PaginationParams(); + + const path = '/search'; + + final payload = jsonEncode({ + 'filter_conditions': filter, + 'sort': sort, + 'query': query, + ...pagination.toJson(), + }); + + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {'results': []})); + + final res = await generalApi.searchMessages( + filter, + query: query, + sort: sort, + pagination: pagination, + ); + + expect(res, isNotNull); + expect(res.results, isEmpty); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('should run successfully with `messageFilter`', () async { + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const sort = [SortOption('test-field')]; + final messageFilter = Filter.query('key', 'text'); + const pagination = PaginationParams(); + + const path = '/search'; + + final payload = jsonEncode({ + 'filter_conditions': filter, + 'sort': sort, + 'message_filter_conditions': messageFilter, + ...pagination.toJson(), + }); + + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {'results': []})); + + final res = await generalApi.searchMessages( + filter, + messageFilters: messageFilter, + sort: sort, + pagination: pagination, + ); + + expect(res, isNotNull); + expect(res.results, isEmpty); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + }); + + group('queryMembers', () { + test('with `channelId`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const pagination = PaginationParams(); + const sort = [SortOption('test-field')]; + + const path = '/members'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id=$index'), + ); + + final payload = jsonEncode({ + 'type': channelType, + 'filter_conditions': filter, + 'id': channelId, + 'sort': sort, + ...pagination.toJson(), + }); + + when(() => client.get( + path, + queryParameters: { + 'payload': payload, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'members': [...members.map((it) => it.toJson())] + })); + + final res = await generalApi.queryMembers( + channelType, + channelId: channelId, + filter: filter, + pagination: pagination, + sort: sort, + ); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('with `members`', () async { + const channelType = 'test-channel-type'; + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const pagination = PaginationParams(); + const sort = [SortOption('test-field')]; + + const path = '/members'; + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id=$index'), + ); + + final payload = jsonEncode({ + 'type': channelType, + 'filter_conditions': filter, + 'members': members, + 'sort': sort, + ...pagination.toJson(), + }); + + when(() => client.get( + path, + queryParameters: { + 'payload': payload, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'members': [...members.map((it) => it.toJson())] + })); + + final res = await generalApi.queryMembers( + channelType, + filter: filter, + pagination: pagination, + sort: sort, + members: members, + ); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/api/guest_api_test.dart b/packages/stream_chat/test/src/core/api/guest_api_test.dart new file mode 100644 index 00000000..7db74b4a --- /dev/null +++ b/packages/stream_chat/test/src/core/api/guest_api_test.dart @@ -0,0 +1,46 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late GuestApi guestApi; + + setUp(() { + guestApi = GuestApi(client); + }); + + test('getGuestUser', () async { + const accessToken = 'test-guest-token'; + final user = User(id: 'test-user-id'); + + const path = '/guest'; + + when(() => client.post( + path, + data: {'user': user}, + )).thenAnswer((_) async => successResponse(path, data: { + 'access_token': accessToken, + 'user': user.toJson(), + })); + + final res = await guestApi.getGuestUser(user); + + expect(res, isNotNull); + expect(res.accessToken, accessToken); + expect(res.user.id, user.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/message_api_test.dart b/packages/stream_chat/test/src/core/api/message_api_test.dart new file mode 100644 index 00000000..1aacb6d2 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/message_api_test.dart @@ -0,0 +1,429 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late MessageApi messageApi; + + setUp(() { + messageApi = MessageApi(client); + }); + + test('sendMessage', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'test-message-text'); + + const path = '/channels/$channelType/$channelId/message'; + + when(() => client.post( + path, + data: { + 'message': message, + 'skip_push': false, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + })); + + final res = await messageApi.sendMessage(channelId, channelType, message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendMessage with skipPush: true', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final message = Message(id: 'test-message-id', text: 'test-message-text'); + + const path = '/channels/$channelType/$channelId/message'; + + when(() => client.post( + path, + data: { + 'message': message, + 'skip_push': true, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + })); + + final res = await messageApi.sendMessage( + channelId, + channelType, + message, + skipPush: true, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('getMessagesById', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageIds = ['test-message-id-1', 'test-message-id-2']; + + const path = '/channels/$channelType/$channelId/messages'; + + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + when(() => client.get( + path, + queryParameters: {'ids': messageIds.join(',')}, + )).thenAnswer((_) async => successResponse(path, data: { + 'messages': [...messages.map((it) => it.toJson())], + })); + + final res = await messageApi.getMessagesById( + channelId, + channelType, + messageIds, + ); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('getMessage', () async { + const messageId = 'test-message-id'; + + const path = '/messages/$messageId'; + + final message = Message(id: messageId); + + when(() => client.get(path)).thenAnswer((_) async => + successResponse(path, data: {'message': message.toJson()})); + + final res = await messageApi.getMessage(messageId); + + expect(res, isNotNull); + expect(res.message.id, messageId); + + verify(() => client.get(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateMessage', () async { + final message = Message(id: 'test-message-id'); + + final path = '/messages/${message.id}'; + + when(() => client.post( + path, + data: {'message': message}, + )).thenAnswer( + (_) async => successResponse(path, data: {'message': message.toJson()}), + ); + + final res = await messageApi.updateMessage(message); + + expect(res, isNotNull); + expect(res.message.id, message.id); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('partialUpdateMessage', () async { + const messageId = 'test-message-id'; + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + const path = '/messages/$messageId'; + final message = Message(id: 'test-message-id', text: set['text']); + + when(() => client.put( + path, + data: {'set': set, 'unset': unset}, + )).thenAnswer( + (_) async => successResponse(path, data: {'message': message.toJson()}), + ); + + final res = await messageApi.partialUpdateMessage( + messageId, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); + + verify(() => client.put( + path, + data: {'set': set, 'unset': unset}, + )).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteMessage', () async { + const messageId = 'test-message-id'; + + const path = '/messages/$messageId'; + + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse(path, data: {}), + ); + + final res = await messageApi.deleteMessage(messageId); + + expect(res, isNotNull); + + verify(() => client.delete(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendAction', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const messageId = 'test-message-id'; + const formData = {'test-key': 'test-data'}; + + const path = '/messages/$messageId/action'; + + when(() => client.post( + path, + data: { + 'id': channelId, + 'type': channelType, + 'form_data': formData, + 'message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await messageApi.sendAction( + channelId, + channelType, + messageId, + formData, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendReaction', () async { + const messageId = 'test-message-id'; + const reactionType = 'test-reaction-type'; + const extraData = {'test-key': 'test-data'}; + + const path = '/messages/$messageId/reaction'; + + final message = Message(id: messageId); + final reaction = Reaction(type: reactionType, messageId: messageId); + + when(() => client.post( + path, + data: { + 'reaction': Map.from(extraData) + ..addAll({'type': reactionType}), + 'enforce_unique': false, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + 'reaction': reaction.toJson(), + })); + + final res = await messageApi.sendReaction( + messageId, + reactionType, + extraData: extraData, + ); + + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.messageId, messageId); + expect(res.reaction.type, reactionType); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('sendReaction with enforceUnique: true', () async { + const messageId = 'test-message-id'; + const reactionType = 'test-reaction-type'; + const extraData = {'test-key': 'test-data'}; + + const path = '/messages/$messageId/reaction'; + + final message = Message(id: messageId); + final reaction = Reaction(type: reactionType, messageId: messageId); + + when(() => client.post( + path, + data: { + 'reaction': Map.from(extraData) + ..addAll({'type': reactionType}), + 'enforce_unique': true, + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': message.toJson(), + 'reaction': reaction.toJson(), + })); + + final res = await messageApi.sendReaction( + messageId, + reactionType, + extraData: extraData, + enforceUnique: true, + ); + + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.messageId, messageId); + expect(res.reaction.type, reactionType); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('deleteReaction', () async { + const messageId = 'test-message-id'; + const reactionType = 'test-reaction-type'; + + const path = '/messages/$messageId/reaction/$reactionType'; + + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await messageApi.deleteReaction(messageId, reactionType); + + expect(res, isNotNull); + + verify(() => client.delete(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('getReactions', () async { + const messageId = 'test-message-id'; + const options = PaginationParams(); + + const path = '/messages/$messageId/reactions'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when(() => client.get( + path, + queryParameters: { + ...const PaginationParams().toJson(), + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'reactions': [...reactions.map((it) => it.toJson())] + })); + + final res = await messageApi.getReactions(messageId, pagination: options); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('translateMessage', () async { + const messageId = 'test-message-id'; + const messageText = 'hello'; + const language = 'hi'; // Hindi + final message = Message(id: messageId, text: messageText); + + final path = '/messages/${message.id}/translate'; + + const translatedMessageText = 'ā¤¨ā¤Žā¤¸āĨā¤¤āĨ‡'; + final translatedMessage = TranslatedMessage(const { + language: translatedMessageText, + }); + + when(() => client.post( + path, + data: {'language': language}, + )).thenAnswer((_) async => successResponse(path, data: { + 'message': translatedMessage.toJson(), + })); + + final res = await messageApi.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n?.containsKey(language), isTrue); + expect(res.message.i18n?[language], translatedMessageText); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('getReplies', () async { + const parentId = 'test-parent-id'; + const options = PaginationParams(); + + const path = '/messages/$parentId/replies'; + + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); + + when(() => client.get( + path, + queryParameters: { + ...options.toJson(), + }, + )).thenAnswer((_) async => successResponse(path, data: { + 'messages': [...messages.map((it) => it.toJson())] + })); + + final res = await messageApi.getReplies(parentId, options: options); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + expect(res.messages.every((it) => it.parentId == parentId), isTrue); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/api/moderation_api_test.dart b/packages/stream_chat/test/src/core/api/moderation_api_test.dart new file mode 100644 index 00000000..69d85fd9 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/moderation_api_test.dart @@ -0,0 +1,240 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late ModerationApi moderationApi; + + setUp(() { + moderationApi = ModerationApi(client); + }); + + test('muteUser', () async { + const userId = 'test-user-id'; + + const path = '/moderation/mute'; + + when( + () => client.post( + path, + data: {'target_id': userId}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await moderationApi.muteUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unmuteUser', () async { + const userId = 'test-user-id'; + + const path = '/moderation/unmute'; + + when( + () => client.post( + path, + data: {'target_id': userId}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await moderationApi.unmuteUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('muteChannel', () async { + const channelCid = 'test-channel-cid'; + const expiration = Duration(days: 3); + + const path = '/moderation/mute/channel'; + + when(() => client.post( + path, + data: { + 'channel_cid': channelCid, + 'expiration': expiration.inMilliseconds, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.muteChannel( + channelCid, + expiration: expiration, + ); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unmuteChannel', () async { + const channelCid = 'test-channel-cid'; + + const path = '/moderation/unmute/channel'; + + when(() => client.post( + path, + data: {'channel_cid': channelCid}, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.unmuteChannel(channelCid); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('flagMessage', () async { + const messageId = 'test-message-id'; + + const path = '/moderation/flag'; + + when(() => client.post( + path, + data: { + 'target_message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.flagMessage(messageId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unflagMessage', () async { + const messageId = 'test-message-id'; + + const path = '/moderation/unflag'; + + when(() => client.post( + path, + data: { + 'target_message_id': messageId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.unflagMessage(messageId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('flagUser', () async { + const userId = 'test-message-id'; + + const path = '/moderation/flag'; + + when(() => client.post(path, data: { + 'target_user_id': userId, + })) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.flagUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unflagUser', () async { + const userId = 'test-message-id'; + + const path = '/moderation/unflag'; + + when(() => client.post( + path, + data: { + 'target_user_id': userId, + }, + )) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.unflagUser(userId); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('banUser', () async { + const targetUserId = 'test-target-user-id'; + const options = {'key': 'value'}; + + const path = '/moderation/ban'; + + when(() => client.post(path, data: { + 'target_user_id': targetUserId, + ...options, + })) + .thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await moderationApi.banUser(targetUserId, options: options); + + expect(res, isNotNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unbanUser', () async { + const targetUserId = 'test-target-user-id'; + const options = {'key': 'value'}; + + const path = '/moderation/ban'; + + when( + () => client.delete( + path, + queryParameters: { + 'target_user_id': targetUserId, + ...options, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await moderationApi.unbanUser(targetUserId, options: options); + + expect(res, isNotNull); + + verify( + () => client.delete(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/api/requests_test.dart b/packages/stream_chat/test/src/core/api/requests_test.dart similarity index 100% rename from packages/stream_chat/test/src/api/requests_test.dart rename to packages/stream_chat/test/src/core/api/requests_test.dart diff --git a/packages/stream_chat/test/src/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart similarity index 99% rename from packages/stream_chat/test/src/api/responses_test.dart rename to packages/stream_chat/test/src/core/api/responses_test.dart index f91248af..77e7abc3 100644 --- a/packages/stream_chat/test/src/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -1,12 +1,12 @@ import 'dart:convert'; import 'package:test/test.dart'; -import 'package:stream_chat/src/api/responses.dart'; -import 'package:stream_chat/src/models/device.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/read.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/models/device.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; import 'package:stream_chat/stream_chat.dart'; void main() { diff --git a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart new file mode 100644 index 00000000..f61746cd --- /dev/null +++ b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart @@ -0,0 +1,49 @@ +import 'package:stream_chat/src/core/api/stream_chat_api.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + const apiKey = 'test-api-key'; + late final client = MockHttpClient(); + late StreamChatApi streamChatApi; + + setUp(() { + streamChatApi = StreamChatApi( + apiKey, + client: client, + ); + }); + + test('`.user`', () { + expect(streamChatApi.user, isNotNull); + }); + + test('`.guest`', () { + expect(streamChatApi.guest, isNotNull); + }); + + test('`.message`', () { + expect(streamChatApi.message, isNotNull); + }); + + test('`.channel`', () { + expect(streamChatApi.channel, isNotNull); + }); + + test('`.device`', () { + expect(streamChatApi.device, isNotNull); + }); + + test('`.moderation`', () { + expect(streamChatApi.moderation, isNotNull); + }); + + test('`.general`', () { + expect(streamChatApi.general, isNotNull); + }); + + test('`.fileUploader`', () { + expect(streamChatApi.fileUploader, isNotNull); + }); +} diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart new file mode 100644 index 00000000..6eecbee1 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late UserApi userApi; + + setUp(() { + userApi = UserApi(client); + }); + + test('queryUsers', () async { + const presence = true; + final filter = Filter.in_('cid', const ['test-cid-1', 'test-cid-2']); + const sort = [SortOption('test-field')]; + const pagination = PaginationParams(); + + const path = '/users'; + + final users = List.generate(3, (index) => User(id: 'test-user-id-$index')); + + when(() => client.get(path, queryParameters: { + 'payload': jsonEncode({ + 'presence': presence, + 'sort': sort, + 'filter_conditions': filter, + ...pagination.toJson(), + }), + })).thenAnswer((_) async => successResponse(path, data: { + 'users': [...users.map((it) => it.toJson())] + })); + + final res = await userApi.queryUsers( + presence: presence, + filter: filter, + sort: sort, + pagination: pagination, + ); + + expect(res, isNotNull); + expect(res.users.length, users.length); + + verify( + () => client.get(path, queryParameters: any(named: 'queryParameters')), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateUsers', () async { + final users = List.generate(3, (index) => User(id: 'test-user-id-$index')); + + const path = '/users'; + + final updatedUsers = {for (final user in users) user.id: user}; + + when(() => client.post(path, data: { + 'users': updatedUsers, + })).thenAnswer((_) async => successResponse(path, + data: { + 'users': updatedUsers + .map((key, value) => MapEntry(key, value.toJson())) + })); + + final res = await userApi.updateUsers(users); + + expect(res, isNotNull); + expect(res.users.length, updatedUsers.length); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); +} diff --git a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart new file mode 100644 index 00000000..a32e0d71 --- /dev/null +++ b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart @@ -0,0 +1,126 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamChatError', () { + test('should match if message is same', () { + const message = 'test-error-message'; + const error = StreamChatError(message); + const error2 = StreamChatError(message); + + expect(error, error2); + }); + + test('`.toString`', () { + const message = 'test-error-message'; + const error = StreamChatError(message); + + expect(error.toString(), 'StreamChatError(message: $message)'); + }); + }); + + group('StreamWebSocketError', () { + test('.fromStreamError', () { + final data = ErrorResponse()..code = 333; + final error = StreamWebSocketError.fromStreamError(data.toJson()); + expect(error, isNotNull); + expect(error.code, data.code); + }); + + test('should match if message and data.code is same', () { + const message = 'test-error-message'; + final data = ErrorResponse()..code = 333; + final error = StreamWebSocketError(message, data: data); + final error2 = StreamWebSocketError(message, data: data); + + expect(error, error2); + }); + + test('`.toString`', () { + const message = 'test-error-message'; + final data = ErrorResponse()..code = 333; + final error = StreamWebSocketError(message, data: data); + + expect( + error.toString(), + 'WebSocketError(message: $message, data: $data)', + ); + }); + }); + + group('StreamChatNetworkError', () { + test('.raw', () { + const code = 333; + const message = 'test-error-message'; + final error = StreamChatNetworkError.raw(code: code, message: message); + expect(error, isNotNull); + expect(error.code, code); + expect(error.message, message); + }); + + test('.fromDioError', () { + const code = 333; + const statusCode = 666; + const message = 'test-error-message'; + final options = RequestOptions(path: 'test-path'); + final data = ErrorResponse() + ..code = code + ..statusCode = statusCode + ..message = message; + final dioError = DioError( + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: data.statusCode, + data: data.toJson(), + ), + ); + final error = StreamChatNetworkError.fromDioError(dioError); + expect(error, isNotNull); + expect(error.code, code); + expect(error.message, message); + expect(error.statusCode, statusCode); + expect(error.data?.code, data.code); + expect(error.data?.statusCode, data.statusCode); + expect(error.data?.message, data.message); + }); + + test('should match if message, code and statusCode is same', () { + const code = 333; + const statusCode = 666; + const message = 'test-error-message'; + final error = StreamChatNetworkError.raw( + code: code, + statusCode: statusCode, + message: message, + ); + final error2 = StreamChatNetworkError.raw( + code: code, + statusCode: statusCode, + message: message, + ); + + expect(error, error2); + }); + + test('`.retriable` should return true if data is not present', () { + const errorCode = ChatErrorCode.tokenExpired; + final error = StreamChatNetworkError(errorCode); + + expect(error.isRetriable, isTrue); + }); + + test('`.toString`', () { + const errorCode = ChatErrorCode.tokenExpired; + final error = StreamChatNetworkError(errorCode); + expect( + error.toString(), + 'StreamChatNetworkError(' + 'code: ${errorCode.code}, ' + 'message: ${errorCode.message})', + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/http/connection_id_manager_test.dart b/packages/stream_chat/test/src/core/http/connection_id_manager_test.dart new file mode 100644 index 00000000..ad70283f --- /dev/null +++ b/packages/stream_chat/test/src/core/http/connection_id_manager_test.dart @@ -0,0 +1,38 @@ +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:test/test.dart'; + +void main() { + late ConnectionIdManager connectionIdManager; + + setUp(() { + connectionIdManager = ConnectionIdManager(); + }); + + tearDown(() { + connectionIdManager.reset(); + }); + + test('`setConnectionId` should set connectionId', () { + expect(connectionIdManager.connectionId, isNull); + expect(connectionIdManager.hasConnectionId, isFalse); + + const connectionId = 'test-connection-id'; + connectionIdManager.setConnectionId(connectionId); + + expect(connectionIdManager.connectionId, connectionId); + expect(connectionIdManager.hasConnectionId, isTrue); + }); + + test('`reset` should clear the connectionId', () { + const connectionId = 'test-connection-id'; + connectionIdManager.setConnectionId(connectionId); + + expect(connectionIdManager.connectionId, connectionId); + expect(connectionIdManager.hasConnectionId, isTrue); + + connectionIdManager.reset(); + + expect(connectionIdManager.connectionId, isNull); + expect(connectionIdManager.hasConnectionId, isFalse); + }); +} diff --git a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart new file mode 100644 index 00000000..82dcdfb9 --- /dev/null +++ b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart @@ -0,0 +1,270 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../../mocks.dart'; + +void main() { + late StreamHttpClient client; + late TokenManager tokenManager; + late AuthInterceptor authInterceptor; + + setUp(() { + client = MockHttpClient(); + tokenManager = MockTokenManager(); + authInterceptor = AuthInterceptor(client, tokenManager); + }); + + test( + '`onRequest` should add userId, authToken, authType in the request', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final headers = options.headers; + final queryParams = options.queryParameters; + expect(headers.containsKey('Authorization'), isFalse); + expect(headers.containsKey('stream-auth-type'), isFalse); + expect(queryParams.containsKey('user_id'), isFalse); + + final token = Token.development('test-user-id'); + when(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) + .thenAnswer((_) async => token); + + authInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updateHeaders = updatedOptions.headers; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updateHeaders.containsKey('Authorization'), isTrue); + expect(updateHeaders['Authorization'], token.rawValue); + expect(updateHeaders.containsKey('stream-auth-type'), isTrue); + expect(updateHeaders['stream-auth-type'], token.authType.raw); + expect(updatedQueryParams.containsKey('user_id'), isTrue); + expect(updatedQueryParams['user_id'], token.userId); + + verify(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) + .called(1); + verifyNoMoreInteractions(tokenManager); + }, + ); + + test( + '`onRequest` should reject with error if `tokenManager.loadToken` throws', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + authInterceptor.onRequest(options, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + var error = (e as dynamic).data; + expect(error, isA()); + error = (error as StreamChatDioError).error; + expect(error.code, ChatErrorCode.undefinedToken.code); + expect(error.message, ChatErrorCode.undefinedToken.message); + } + }, + ); + + test('`onError` should retry the request with refreshed token', () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = ChatErrorCode.tokenExpired; + final errorResponse = ErrorResponse() + ..code = code.code + ..message = code.message; + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(false); + + when(() => client.lock()).thenReturn(() {}); + + final token = Token.development('test-user-id'); + when(() => tokenManager.loadToken(refresh: true)) + .thenAnswer((_) async => token); + + when(() => client.unlock()).thenReturn(() {}); + + when(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).thenAnswer((_) async => Response( + requestOptions: options, + statusCode: 200, + )); + + authInterceptor.onError(err, handler); + + final res = await handler.future; + + var data = res.data; + expect(data, isA()); + data = data as Response; + expect(data, isNotNull); + expect(data.statusCode, 200); + expect(data.requestOptions.path, path); + + verify(() => tokenManager.isStatic).called(1); + + verify(() => client.lock()).called(1); + + verify(() => tokenManager.loadToken(refresh: true)).called(1); + verifyNoMoreInteractions(tokenManager); + + verify(() => client.unlock()).called(1); + verify(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(client); + }); + + test( + '`onError` should reject with error if retried request throws', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = ChatErrorCode.tokenExpired; + final errorResponse = ErrorResponse() + ..code = code.code + ..message = code.message; + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(false); + + when(() => client.lock()).thenReturn(() {}); + + final token = Token.development('test-user-id'); + when(() => tokenManager.loadToken(refresh: true)) + .thenAnswer((_) async => token); + + when(() => client.unlock()).thenReturn(() {}); + + when(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).thenThrow(err); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + } + + verify(() => tokenManager.isStatic).called(1); + + verify(() => client.lock()).called(1); + + verify(() => tokenManager.loadToken(refresh: true)).called(1); + verifyNoMoreInteractions(tokenManager); + + verify(() => client.unlock()).called(1); + verify(() => client.request( + path, + data: options.data, + onReceiveProgress: options.onReceiveProgress, + onSendProgress: options.onSendProgress, + queryParameters: options.queryParameters, + cancelToken: options.cancelToken, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(client); + }, + ); + + test( + '`onError` should reject with error if `tokenManager.isStatic` is true', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = ChatErrorCode.tokenExpired; + final errorResponse = ErrorResponse() + ..code = code.code + ..message = code.message; + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(true); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + final response = StreamChatNetworkError.fromDioError(error); + expect(response.errorCode, code); + } + + verify(() => tokenManager.isStatic).called(1); + verifyNoMoreInteractions(tokenManager); + }, + ); + + test( + '`onError` should reject with error if error is not a `tokenExpired error`', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + final response = Response(requestOptions: options); + final err = DioError(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + } + }, + ); +} diff --git a/packages/stream_chat/test/src/core/http/interceptor/connection_id_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/connection_id_interceptor_test.dart new file mode 100644 index 00000000..9acabccf --- /dev/null +++ b/packages/stream_chat/test/src/core/http/interceptor/connection_id_interceptor_test.dart @@ -0,0 +1,67 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; +import 'package:test/test.dart'; + +import '../../../mocks.dart'; + +void main() { + late ConnectionIdManager connectionIdManager; + late ConnectionIdInterceptor connectionIdInterceptor; + + setUp(() { + connectionIdManager = MockConnectionIdManager(); + connectionIdInterceptor = ConnectionIdInterceptor(connectionIdManager); + }); + + test( + '`onRequest` should add connectionId in the request', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final queryParams = options.queryParameters; + expect(queryParams.containsKey('connection_id'), isFalse); + + const connectionId = 'test-connection-id'; + when(() => connectionIdManager.hasConnectionId).thenReturn(true); + when(() => connectionIdManager.connectionId).thenReturn(connectionId); + + connectionIdInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updatedQueryParams.containsKey('connection_id'), isTrue); + expect(updatedQueryParams['connection_id'], connectionId); + + verify(() => connectionIdManager.hasConnectionId).called(1); + verify(() => connectionIdManager.connectionId).called(1); + verifyNoMoreInteractions(connectionIdManager); + }, + ); + + test( + '`onRequest` should not add connectionId if `hasConnectionId` is false', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final queryParams = options.queryParameters; + expect(queryParams.containsKey('connection_id'), isFalse); + + when(() => connectionIdManager.hasConnectionId).thenReturn(false); + + connectionIdInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updatedQueryParams.containsKey('connection_id'), isFalse); + + verify(() => connectionIdManager.hasConnectionId).called(1); + verifyNoMoreInteractions(connectionIdManager); + }, + ); +} diff --git a/packages/stream_chat/test/src/core/http/stream_chat_dio_error_test.dart b/packages/stream_chat/test/src/core/http/stream_chat_dio_error_test.dart new file mode 100644 index 00000000..f13cf50d --- /dev/null +++ b/packages/stream_chat/test/src/core/http/stream_chat_dio_error_test.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:test/test.dart'; + +void main() { + test('should create a new instance of StreamChatDioError', () { + final error = StreamChatNetworkError(ChatErrorCode.inputError); + final options = RequestOptions(path: 'test-path'); + final dioError = StreamChatDioError( + error: error, + requestOptions: options, + ); + + expect(dioError, isA()); + expect(dioError, isNotNull); + expect(dioError.error, error); + expect(dioError.requestOptions, options); + }); +} diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart new file mode 100644 index 00000000..a03434fa --- /dev/null +++ b/packages/stream_chat/test/src/core/http/stream_http_client_options_test.dart @@ -0,0 +1,53 @@ +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/location.dart'; +import 'package:test/test.dart'; + +void main() { + test('should return the all default set params', () { + const options = StreamHttpClientOptions(); + expect(options.location, isNull); + expect(options.baseUrl, 'https://chat-us-east-1.stream-io-api.com'); + expect(options.connectTimeout, const Duration(seconds: 6)); + expect(options.receiveTimeout, const Duration(seconds: 6)); + }); + + test('should override all the default set params', () { + const options = StreamHttpClientOptions( + baseUrl: 'base-url', + connectTimeout: Duration(seconds: 3), + receiveTimeout: Duration(seconds: 3), + ); + expect(options.location, isNull); + expect(options.baseUrl, 'base-url'); + expect(options.connectTimeout, const Duration(seconds: 3)); + expect(options.receiveTimeout, const Duration(seconds: 3)); + }); + + group('should create baseUrl according to provided location', () { + test('us-east', () { + const options = StreamHttpClientOptions(location: Location.usEast); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-us-east.stream-io-api.com'); + }); + test('eu-west', () { + const options = StreamHttpClientOptions(location: Location.euWest); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-dublin.stream-io-api.com'); + }); + test('mumbai', () { + const options = StreamHttpClientOptions(location: Location.mumbai); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-mumbai.stream-io-api.com'); + }); + test('sydney', () { + const options = StreamHttpClientOptions(location: Location.sydney); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-sydney.stream-io-api.com'); + }); + test('singapore', () { + const options = StreamHttpClientOptions(location: Location.singapore); + expect(options.location, isNotNull); + expect(options.baseUrl, 'https://chat-proxy-singapore.stream-io-api.com'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart new file mode 100644 index 00000000..ece00efd --- /dev/null +++ b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart @@ -0,0 +1,528 @@ +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/error/error.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; +import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; +import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path) => Response( + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + DioError throwableError( + String path, { + StreamChatNetworkError? error, + bool streamChatDioError = false, + }) { + if (streamChatDioError) assert(error != null, ''); + final options = RequestOptions(path: path); + final data = ErrorResponse() + ..code = error?.code + ..statusCode = error?.statusCode + ..message = error?.message; + DioError? dioError; + if (streamChatDioError) { + dioError = StreamChatDioError(error: error!, requestOptions: options); + } else { + dioError = DioError( + error: error, + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: data.statusCode, + data: data.toJson(), + ), + ); + } + return dioError; + } + + test('AuthInterceptor should be added if tokenManager is provided', () { + const apiKey = 'api-key'; + final client = StreamHttpClient(apiKey, tokenManager: TokenManager()); + + expect(client.httpClient.interceptors.length, 1); + expect(client.httpClient.interceptors.first, isA()); + }); + + test( + 'connectionIdInterceptor should be added if connectionIdManager is provided', + () { + const apiKey = 'api-key'; + final client = StreamHttpClient( + apiKey, + connectionIdManager: ConnectionIdManager(), + ); + + expect(client.httpClient.interceptors.length, 1); + expect( + client.httpClient.interceptors.first, + isA(), + ); + }, + ); + + test('loggingInterceptor should be added if logger is provided', () { + const apiKey = 'api-key'; + final client = StreamHttpClient( + apiKey, + logger: Logger('test-logger'), + ); + + expect(client.httpClient.interceptors.length, 1); + expect( + client.httpClient.interceptors.first, + isA(), + ); + }); + + test('loggingInterceptor should log requests', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = StreamHttpClient(apiKey, logger: logger); + + try { + await client.get('path'); + } catch (_) {} + + verify(() => logger.info(any())).called(16); + }); + + test('loggingInterceptor should log error', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = StreamHttpClient(apiKey, logger: logger); + + try { + await client.get('path'); + } catch (_) {} + + verify(() => logger.severe(any())).called(8); + }); + + test('`.lock` should lock the dio client', () async { + final client = StreamHttpClient('api-key'); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + client.lock(); + expect(client.httpClient.interceptors.requestLock.locked, isTrue); + }); + + test('`.unlock` should unlock the dio client', () async { + final client = StreamHttpClient('api-key'); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + client.lock(); + expect(client.httpClient.interceptors.requestLock.locked, isTrue); + client.unlock(); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + }); + + test('`.clear` should clear and unlock the dio client', () async { + final client = StreamHttpClient('api-key')..clear(); + expect(client.httpClient.interceptors.requestLock.locked, isFalse); + }); + + test('`.close` should close the dio client', () async { + final client = StreamHttpClient('api-key')..close(force: true); + try { + await client.get('path'); + } on StreamChatNetworkError catch (e) { + expect(e, isA()); + expect(e.message, "Dio can't establish new connection after closed."); + } + }); + + test('`.get` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-get-api-path'; + when(() => dio.get( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.get(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.get( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test('`.get` should throw an instance of `StreamChatNetworkError`', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-get-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.get( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.get(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.get( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test('`.post` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-post-api-path'; + when(() => dio.post( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.post(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.post( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.post` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-post-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.post( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.post(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.post( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.delete` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-delete-api-path'; + when(() => dio.delete( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.delete(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.delete( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.delete` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-delete-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.delete( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.delete(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.delete( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.patch` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-patch-api-path'; + when(() => dio.patch( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.patch(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.patch( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.patch` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-patch-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.patch( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.patch(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.patch( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.put` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-put-api-path'; + when(() => dio.put( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.put(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.put( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.put` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-put-api-path'; + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.put( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.put(path); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.put( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.postFile` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-delete-api-path'; + final file = MultipartFile.fromBytes([]); + + when(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.postFile(path, file); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.postFile` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-post-file-api-path'; + final file = MultipartFile.fromBytes([]); + + final error = throwableError( + path, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.postFile(path, file); + } catch (e) { + expect(e, isA()); + expect(e, StreamChatNetworkError.fromDioError(error)); + } + + verify(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.request` should return response successfully', () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-request-api-path'; + when(() => dio.request( + path, + options: any(named: 'options'), + )).thenAnswer((_) async => successResponse(path)); + + final res = await client.request(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.request( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.request` should throw an instance of `StreamChatNetworkError`', + () async { + final dio = MockDio(); + final client = StreamHttpClient('api-key', dio: dio); + + const path = 'test-put-api-path'; + final error = throwableError( + path, + streamChatDioError: true, + error: StreamChatNetworkError(ChatErrorCode.internalSystemError), + ); + when(() => dio.request( + path, + options: any(named: 'options'), + )).thenThrow(error); + + try { + await client.request(path); + } catch (e) { + expect(e, isA()); + expect(e, error.error); + } + + verify(() => dio.request( + path, + options: any(named: 'options'), + )).called(1); + verifyNoMoreInteractions(dio); + }, + ); +} diff --git a/packages/stream_chat/test/src/core/http/token_manager_test.dart b/packages/stream_chat/test/src/core/http/token_manager_test.dart new file mode 100644 index 00000000..a1a38fa5 --- /dev/null +++ b/packages/stream_chat/test/src/core/http/token_manager_test.dart @@ -0,0 +1,141 @@ +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:test/test.dart'; + +void main() { + late TokenManager tokenManager; + + setUp(() { + tokenManager = TokenManager(); + }); + + tearDown(() { + tokenManager.reset(); + }); + + test('`setTokenOrProvider` should set token', () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + final token = Token.development(userId); + final returnedToken = await tokenManager.setTokenOrProvider( + userId, + token: token, + ); + + expect(returnedToken, token); + expect(tokenManager.userId, userId); + expect(tokenManager.isStatic, isTrue); + }); + + test('`setTokenOrProvider` should set tokenProvider', () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + Future tokenProvider(String userId) async => + Token.development(userId).rawValue; + final returnedToken = await tokenManager.setTokenOrProvider( + userId, + provider: tokenProvider, + ); + + expect(returnedToken, isNotNull); + expect(tokenManager.userId, userId); + expect(tokenManager.isStatic, isFalse); + }); + + test( + '`setTokenOrProvider` should throw if both token and provider is not provided', + () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + try { + await tokenManager.setTokenOrProvider(userId); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + '`setTokenOrProvider` should throw if both token and provider is provided', + () async { + expect(tokenManager.userId, isNull); + + const userId = 'test-user-id'; + final token = Token.development(userId); + Future tokenProvider(String userId) async => + Token.development(userId).rawValue; + try { + await tokenManager.setTokenOrProvider( + userId, + token: token, + provider: tokenProvider, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + '`.loadToken` should return token set via `setToken`', + () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + await tokenManager.setTokenOrProvider(userId, token: token); + + final returnedToken = await tokenManager.loadToken(); + expect(returnedToken, token); + }, + ); + + test( + '`.loadToken` should return token set via `setProvider`', + () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + Future tokenProvider(String userId) async => token.rawValue; + await tokenManager.setTokenOrProvider(userId, provider: tokenProvider); + + final returnedToken = await tokenManager.loadToken(); + expect(returnedToken, token); + }, + ); + + test( + '`.loadToken` should return refreshed token set via `setProvider`', + () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + final refreshToken = Token.development(userId); + + var refresh = false; + + Future tokenProvider(String userId) async { + if (refresh) return refreshToken.rawValue; + return token.rawValue; + } + + await tokenManager.setTokenOrProvider(userId, provider: tokenProvider); + + final returnedToken = await tokenManager.loadToken(); + expect(returnedToken, token); + + refresh = true; + final returnedRefreshToken = await tokenManager.loadToken(refresh: true); + expect(returnedRefreshToken, refreshToken); + }, + ); + + test('`.reset` should reset the tokenManager', () async { + const userId = 'test-user-id'; + final token = Token.development(userId); + await tokenManager.setTokenOrProvider(userId, token: token); + expect(tokenManager.userId, userId); + + tokenManager.reset(); + expect(tokenManager.userId, isNull); + }); +} diff --git a/packages/stream_chat/test/src/core/http/token_test.dart b/packages/stream_chat/test/src/core/http/token_test.dart new file mode 100644 index 00000000..b448884e --- /dev/null +++ b/packages/stream_chat/test/src/core/http/token_test.dart @@ -0,0 +1,57 @@ +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +void main() { + test('`.anonymous` should create anonymous token with passed userId', () { + const userId = 'test-user-id'; + final token = Token.anonymous(userId: userId); + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.rawValue, isEmpty); + expect(token.authType, AuthType.anonymous); + expect(token.authType.raw, AuthType.anonymous.raw); + }); + + test('`.fromRawValue` should create token from rawValue', () { + const userId = 'test-user-id'; + final devToken = Token.development(userId); + final token = Token.fromRawValue(devToken.rawValue); + expect(token, devToken); + }); + + test('`.fromRawValue` should throw if does not contain `user_id`', () { + const badToken = 'bad-token-without-a-user-id'; + try { + Token.fromRawValue(badToken); + } catch (e) { + expect(e, isA()); + } + }); + + test('`.development` should create a dev-token with provided user-id', () { + const userId = 'test-user-id'; + final token = Token.development(userId); + expect(token, isNotNull); + expect(token.userId, userId); + expect(token.rawValue, isNotEmpty); + expect(token.authType, AuthType.jwt); + expect(token.authType.raw, AuthType.jwt.raw); + }); + + test( + '`.guest` should create a guest-token with provided user and provider', + () async { + final user = User(id: 'test-user-id'); + Future provider(User user) async => + Token.development(user.id).rawValue; + + final token = await Token.guest(user, provider); + expect(token, isNotNull); + expect(token.userId, user.id); + expect(token.rawValue, isNotEmpty); + expect(token.authType, AuthType.jwt); + expect(token.authType.raw, AuthType.jwt.raw); + }, + ); +} diff --git a/packages/stream_chat/test/src/models/action_test.dart b/packages/stream_chat/test/src/core/models/action_test.dart similarity index 71% rename from packages/stream_chat/test/src/models/action_test.dart rename to packages/stream_chat/test/src/core/models/action_test.dart index 22e66078..0cf1c996 100644 --- a/packages/stream_chat/test/src/models/action_test.dart +++ b/packages/stream_chat/test/src/core/models/action_test.dart @@ -1,21 +1,12 @@ -import 'dart:convert'; - +import 'package:stream_chat/src/core/models/action.dart'; import 'package:test/test.dart'; -import 'package:stream_chat/src/models/action.dart'; + +import '../../utils.dart'; void main() { group('src/models/action', () { - const jsonExample = ''' - { - "name": "name", - "style": "style", - "text": "text", - "type": "type", - "value": "value" - }'''; - test('should parse json correctly', () { - final action = Action.fromJson(json.decode(jsonExample)); + final action = Action.fromJson(jsonFixture('action.json')); expect(action.name, 'name'); expect(action.style, 'style'); expect(action.text, 'text'); diff --git a/packages/stream_chat/test/src/models/attachment_test.dart b/packages/stream_chat/test/src/core/models/attachment_test.dart similarity index 55% rename from packages/stream_chat/test/src/models/attachment_test.dart rename to packages/stream_chat/test/src/core/models/attachment_test.dart index 1342e72e..37716656 100644 --- a/packages/stream_chat/test/src/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -1,44 +1,13 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/action.dart'; -import 'package:stream_chat/src/models/attachment.dart'; +import 'package:stream_chat/src/core/models/action.dart'; +import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:test/test.dart'; +import '../../utils.dart'; + void main() { group('src/models/attachment', () { - const jsonExample = ''' - { - "type": "giphy", - "title": "awesome", - "title_link": "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti", - "thumb_url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif", - "actions": [ - { - "name": "image_action", - "text": "Send", - "style": "primary", - "type": "button", - "value": "send" - }, - { - "name": "image_action", - "text": "Shuffle", - "style": "default", - "type": "button", - "value": "shuffle" - }, - { - "name": "image_action", - "text": "Cancel", - "style": "default", - "type": "button", - "value": "cancel" - } - ] -}'''; - test('should parse json correctly', () { - final attachment = Attachment.fromJson(json.decode(jsonExample)); + final attachment = Attachment.fromJson(jsonFixture('attachment.json')); expect(attachment.type, 'giphy'); expect(attachment.title, 'awesome'); expect( diff --git a/packages/stream_chat/test/src/core/models/channel_state_test.dart b/packages/stream_chat/test/src/core/models/channel_state_test.dart new file mode 100644 index 00000000..8bd17d46 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/channel_state_test.dart @@ -0,0 +1,68 @@ +import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/command.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/channel_state', () { + test('should parse json correctly', () { + final channelState = + ChannelState.fromJson(jsonFixture('channel_state.json')); + expect(channelState.channel?.cid, 'team:dev'); + expect(channelState.channel?.id, 'dev'); + expect(channelState.channel?.team, 'test'); + expect(channelState.channel?.type, 'team'); + expect(channelState.channel?.config, isA()); + expect(channelState.channel?.config, isNotNull); + expect(channelState.channel?.config.commands, hasLength(1)); + expect(channelState.channel?.config.commands[0], isA()); + expect(channelState.channel?.lastMessageAt, + DateTime.parse('2020-01-30T13:43:41.062362Z')); + expect(channelState.channel?.createdAt, + DateTime.parse('2019-04-03T18:43:33.213373Z')); + expect(channelState.channel?.updatedAt, + DateTime.parse('2019-04-03T18:43:33.213374Z')); + expect(channelState.channel?.createdBy, isA()); + expect(channelState.channel?.frozen, true); + expect(channelState.channel?.extraData['example'], 1); + expect(channelState.channel?.extraData['name'], '#dev'); + expect( + channelState.channel?.extraData['image'], + 'https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png', + ); + expect(channelState.messages, hasLength(25)); + expect(channelState.messages[0], isA()); + expect(channelState.messages[0], isNotNull); + expect( + channelState.messages[0].createdAt, + DateTime.parse('2020-01-29T03:23:02.843948Z'), + ); + expect(channelState.messages[0].user, isA()); + expect(channelState.watcherCount, 5); + }); + + test('should serialize to json correctly', () { + final j = jsonFixture('channel_state.json'); + final channelState = ChannelState( + channel: ChannelModel.fromJson(j['channel']), + members: [], + messages: + (j['messages'] as List).map((m) => Message.fromJson(m)).toList(), + read: [], + watcherCount: 5, + pinnedMessages: [], + watchers: [], + ); + + expect( + channelState.toJson(), + jsonFixture('channel_state_to_json.json'), + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/channel_test.dart b/packages/stream_chat/test/src/core/models/channel_test.dart similarity index 75% rename from packages/stream_chat/test/src/models/channel_test.dart rename to packages/stream_chat/test/src/core/models/channel_test.dart index 1e69565c..ca734c44 100644 --- a/packages/stream_chat/test/src/models/channel_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_test.dart @@ -1,22 +1,12 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; import 'package:test/test.dart'; +import '../../utils.dart'; + void main() { group('src/models/channel', () { - const jsonExample = ''' - { - "id": "test", - "type": "livestream", - "cid": "livestream:test", - "cats": true, - "fruit": ["bananas", "apples"] - } - '''; - test('should parse json correctly', () { - final channel = ChannelModel.fromJson(json.decode(jsonExample)); + final channel = ChannelModel.fromJson(jsonFixture('channel.json')); expect(channel.id, equals('test')); expect(channel.type, equals('livestream')); expect(channel.cid, equals('livestream:test')); diff --git a/packages/stream_chat/test/src/models/command_test.dart b/packages/stream_chat/test/src/core/models/command_test.dart similarity index 68% rename from packages/stream_chat/test/src/models/command_test.dart rename to packages/stream_chat/test/src/core/models/command_test.dart index 8fafd192..e8ffb81e 100644 --- a/packages/stream_chat/test/src/models/command_test.dart +++ b/packages/stream_chat/test/src/core/models/command_test.dart @@ -1,20 +1,12 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/command.dart'; +import 'package:stream_chat/src/core/models/command.dart'; import 'package:test/test.dart'; +import '../../utils.dart'; + void main() { group('src/models/command', () { - const jsonExample = ''' - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]" - } - '''; - test('should parse json correctly', () { - final command = Command.fromJson(json.decode(jsonExample)); + final command = Command.fromJson(jsonFixture('command.json')); expect(command.name, 'giphy'); expect(command.description, 'Post a random gif to the channel'); expect(command.args, '[text]'); diff --git a/packages/stream_chat/test/src/models/device_test.dart b/packages/stream_chat/test/src/core/models/device_test.dart similarity index 67% rename from packages/stream_chat/test/src/models/device_test.dart rename to packages/stream_chat/test/src/core/models/device_test.dart index 5cbf015d..b3ac707b 100644 --- a/packages/stream_chat/test/src/models/device_test.dart +++ b/packages/stream_chat/test/src/core/models/device_test.dart @@ -1,18 +1,12 @@ -import 'dart:convert'; - +import 'package:stream_chat/src/core/models/device.dart'; import 'package:test/test.dart'; -import 'package:stream_chat/src/models/device.dart'; + +import '../../utils.dart'; void main() { group('src/models/device', () { - const jsonExample = ''' - { - "id": "device-id", - "push_provider": "push-provider" - }'''; - test('should parse json correctly', () { - final device = Device.fromJson(json.decode(jsonExample)); + final device = Device.fromJson(jsonFixture('device.json')); expect(device.id, 'device-id'); expect(device.pushProvider, 'push-provider'); }); diff --git a/packages/stream_chat/test/src/core/models/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart new file mode 100644 index 00000000..0cd9a0ad --- /dev/null +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -0,0 +1,106 @@ +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/event', () { + test('should parse json correctly', () { + final event = Event.fromJson(jsonFixture('event.json')); + expect(event.type, 'type'); + expect(event.cid, 'cid'); + expect(event.connectionId, 'connectionId'); + expect(event.createdAt, isA()); + expect(event.me, isA()); + expect(event.user, isA()); + expect(event.isLocal, false); + }); + + test('should serialize to json correctly', () { + final event = Event( + user: User(id: 'id'), + type: 'type', + cid: 'cid', + connectionId: 'connectionId', + createdAt: DateTime.parse('2020-01-29T03:22:47.63613Z'), + me: OwnUser(id: 'id2'), + totalUnreadCount: 1, + unreadChannels: 1, + online: true, + ); + + expect( + event.toJson(), + { + 'type': 'type', + 'cid': 'cid', + 'connection_id': 'connectionId', + 'created_at': '2020-01-29T03:22:47.636130Z', + 'me': {'id': 'id2'}, + 'user': {'id': 'id'}, + 'reaction': null, + 'message': null, + 'channel': null, + 'total_unread_count': 1, + 'unread_channels': 1, + 'online': true, + 'member': null, + 'channel_id': null, + 'channel_type': null, + 'parent_id': null, + 'is_local': true, + }, + ); + }); + + test('copyWith', () { + final event = Event.fromJson(jsonFixture('event.json')); + var newEvent = event.copyWith(); + expect(newEvent.type, 'type'); + expect(newEvent.cid, 'cid'); + expect(newEvent.connectionId, 'connectionId'); + expect(newEvent.createdAt, isA()); + expect(newEvent.me, isA()); + expect(newEvent.user, isA()); + expect(newEvent.isLocal, false); + + newEvent = event.copyWith( + type: 'test', + cid: 'test', + connectionId: 'test', + extraData: {}, + user: User(id: 'test'), + channelId: 'test', + totalUnreadCount: 2, + channelType: 'testtype', + ); + + expect(newEvent.channelType, 'testtype'); + expect(newEvent.totalUnreadCount, 2); + expect(newEvent.type, 'test'); + expect(newEvent.channelId, 'test'); + expect(newEvent.cid, 'test'); + expect(newEvent.connectionId, 'test'); + expect(newEvent.extraData, {}); + expect(newEvent.user!.id, 'test'); + }); + + group('eventChannel', () { + test('should parse json correctly', () { + final eventChannel = + EventChannel.fromJson(jsonFixture('event_channel.json')); + expect(eventChannel.type, 'messaging'); + expect(eventChannel.cid, + 'messaging:!members-v9ktpgmYysZA-MjgC-GMoeEawFHSelkOdTu6JGxFZWU'); + expect(eventChannel.createdBy!.id, 'super-band-9'); + expect(eventChannel.frozen, false); + expect(eventChannel.members!.length, 2); + expect(eventChannel.memberCount, 2); + expect(eventChannel.config, isA()); + expect(eventChannel.name, 'test'); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/filter_test.dart b/packages/stream_chat/test/src/core/models/filter_test.dart similarity index 99% rename from packages/stream_chat/test/src/models/filter_test.dart rename to packages/stream_chat/test/src/core/models/filter_test.dart index ac026c6c..663ab702 100644 --- a/packages/stream_chat/test/src/models/filter_test.dart +++ b/packages/stream_chat/test/src/core/models/filter_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:test/test.dart'; -import 'package:stream_chat/src/models/filter.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; void main() { group('operators', () { diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart new file mode 100644 index 00000000..4cd8efda --- /dev/null +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -0,0 +1,17 @@ +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/member', () { + test('should parse json correctly', () { + final member = Member.fromJson(jsonFixture('member.json')); + expect(member.user, isA()); + expect(member.role, 'member'); + expect(member.createdAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); + expect(member.updatedAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart new file mode 100644 index 00000000..a5571430 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -0,0 +1,68 @@ +import 'package:stream_chat/src/core/models/attachment.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/message', () { + test('should parse json correctly', () { + final message = Message.fromJson(jsonFixture('message.json')); + expect(message.id, '4637f7e4-a06b-42db-ba5a-8d8270dd926f'); + expect(message.text, + 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA'); + expect(message.type, 'regular'); + expect(message.user, isA()); + expect(message.silent, isA()); + expect(message.attachments, isA>()); + expect(message.latestReactions, isA>()); + expect(message.ownReactions, isA>()); + expect(message.reactionCounts, {'love': 1}); + expect(message.reactionScores, {'love': 1}); + expect(message.createdAt, DateTime.parse('2020-01-28T22:17:31.107978Z')); + expect(message.updatedAt, DateTime.parse('2020-01-28T22:17:31.130506Z')); + expect(message.mentionedUsers, isA>()); + expect(message.pinned, false); + expect(message.pinnedAt, null); + expect(message.pinExpires, null); + expect(message.pinnedBy, null); + }); + + test('should serialize to json correctly', () { + final message = Message( + id: '4637f7e4-a06b-42db-ba5a-8d8270dd926f', + text: + 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA', + attachments: [ + Attachment.fromJson(const { + 'type': 'video', + 'author_name': 'GIPHY', + 'title': 'The Lion King Disney GIF - Find \u0026 Share on GIPHY', + 'title_link': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'text': + '''Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.''', + 'image_url': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'thumb_url': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'asset_url': + 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4', + 'og_scrape_url': + 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA' + }) + ], + showInChannel: true, + parentId: 'parentId', + extraData: const {'hey': 'test'}, + ); + + expect( + message.toJson(), + jsonFixture('message_to_json.json'), + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/mute_test.dart b/packages/stream_chat/test/src/core/models/mute_test.dart new file mode 100644 index 00000000..f1f7b606 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/mute_test.dart @@ -0,0 +1,17 @@ +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/mute.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/mute', () { + test('should parse json correctly', () { + final mute = Mute.fromJson(jsonFixture('mute.json')); + expect(mute.channel, isA()); + expect(mute.user, isA()); + expect(mute.createdAt, DateTime.parse('2020-12-04T10:39:06.512021Z')); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart new file mode 100644 index 00000000..edaf022e --- /dev/null +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -0,0 +1,45 @@ +import 'package:stream_chat/src/core/models/own_user.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/own_user', () { + test('should parse json correctly', () { + final ownUser = OwnUser.fromJson(jsonFixture('own_user.json')); + expect(ownUser.id, 'super-band-9'); + expect(ownUser.role, 'user'); + + expect(ownUser.createdAt, DateTime.parse('2020-03-03T16:48:28.853674Z')); + expect(ownUser.updatedAt, DateTime.parse('2021-05-26T03:22:20.296181Z')); + expect( + ownUser.lastActive, DateTime.parse('2021-06-16T11:59:59.003453014Z')); + expect(ownUser.banned, false); + expect(ownUser.online, true); + expect(ownUser.devices.length, 1); + expect(ownUser.mutes.length, 0); + expect(ownUser.channelMutes.length, 1); + expect(ownUser.totalUnreadCount, 0); + expect(ownUser.unreadChannels, 0); + expect(ownUser.extraData['image'], 'https://placehold.jp/150x150.png'); + expect(ownUser.extraData['name'], 'Proud darkness'); + expect(ownUser.extraData['username'], 'Rioland'); + }); + + test('should initialize a OwnUser from a User correctly', () { + final user = User.fromJson(jsonFixture('user.json')); + final ownUser = OwnUser.fromUser(user); + + expect(ownUser.id, user.id); + expect(ownUser.id, user.id); + expect(ownUser.role, user.role); + expect(ownUser.createdAt, user.createdAt); + expect(ownUser.updatedAt, user.updatedAt); + expect(ownUser.lastActive, user.lastActive); + expect(ownUser.online, user.online); + expect(ownUser.banned, user.banned); + expect(ownUser.extraData, user.extraData); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/reaction_test.dart b/packages/stream_chat/test/src/core/models/reaction_test.dart new file mode 100644 index 00000000..518a223e --- /dev/null +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -0,0 +1,118 @@ +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/reaction', () { + test('should parse json correctly', () { + final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + expect(reaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); + expect(reaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); + expect(reaction.type, 'wow'); + expect( + reaction.user?.toJson(), + User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: { + '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'); + expect(reaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + }); + + test('should serialize to json correctly', () { + final reaction = Reaction( + 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: { + 'image': 'https://randomuser.me/api/portraits/women/45.jpg', + 'name': 'Daisy Morgan' + }), + userId: '2de0297c-f3f2-489d-b930-ef77342edccf', + extraData: {'bananas': 'yes'}, + score: 1, + ); + + expect( + reaction.toJson(), + { + 'message_id': '76cd8c82-b557-4e48-9d12-87995d3a0e04', + 'type': 'wow', + 'score': 1, + 'bananas': 'yes', + }, + ); + }); + + test('copyWith', () { + final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + var newReaction = reaction.copyWith(); + expect(newReaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); + expect( + newReaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); + 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(), + ); + expect(newReaction.score, 1); + expect(newReaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); + expect( + newReaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + + newReaction = reaction.copyWith( + type: 'lol', + createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), + extraData: {}, + messageId: 'test', + score: 2, + user: User(id: 'test'), + userId: 'test', + ); + + expect(newReaction.type, 'lol'); + expect( + newReaction.createdAt, + DateTime.parse('2021-01-28T22:17:31.108742Z'), + ); + expect(newReaction.extraData, {}); + expect(newReaction.messageId, 'test'); + expect(newReaction.score, 2); + expect(newReaction.user, User(id: 'test')); + expect(newReaction.userId, 'test'); + }); + + test('merge', () { + final reaction = Reaction.fromJson(jsonFixture('reaction.json')); + final newReaction = reaction.merge( + Reaction( + type: 'lol', + createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), + extraData: {}, + messageId: 'test', + score: 2, + user: User(id: 'test'), + userId: 'test', + ), + ); + + expect(newReaction.type, 'lol'); + expect( + newReaction.createdAt, + DateTime.parse('2021-01-28T22:17:31.108742Z'), + ); + expect(newReaction.extraData, {}); + expect(newReaction.messageId, 'test'); + expect(newReaction.score, 2); + expect(newReaction.user, User(id: 'test')); + expect(newReaction.userId, 'test'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/read_test.dart b/packages/stream_chat/test/src/core/models/read_test.dart new file mode 100644 index 00000000..94a28f08 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/read_test.dart @@ -0,0 +1,54 @@ +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/read', () { + test('should parse json correctly', () { + final read = Read.fromJson(jsonFixture('read.json')); + expect(read.lastRead, DateTime.parse('2020-01-28T22:17:30.966485504Z')); + expect(read.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + expect(read.unreadMessages, 10); + }); + + test('should serialize to json correctly', () { + final read = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'), + unreadMessages: 10, + ); + + expect(read.toJson(), { + 'user': {'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'}, + 'last_read': '2020-01-28T22:17:30.966485Z', + 'unread_messages': 10, + }); + }); + + test('copyWith', () { + final read = Read.fromJson(jsonFixture('read.json')); + var newRead = read.copyWith(); + expect( + newRead.lastRead, + DateTime.parse('2020-01-28T22:17:30.966485504Z'), + ); + expect(newRead.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + expect(newRead.unreadMessages, 10); + + newRead = read.copyWith( + user: User(id: 'test'), + lastRead: DateTime.parse('2021-01-28T22:17:30.966485504Z'), + unreadMessages: 2, + ); + + expect( + newRead.lastRead, + DateTime.parse('2021-01-28T22:17:30.966485504Z'), + ); + expect(newRead.user.id, 'test'); + expect(newRead.unreadMessages, 2); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/serialization_test.dart b/packages/stream_chat/test/src/core/models/serialization_test.dart similarity index 80% rename from packages/stream_chat/test/src/models/serialization_test.dart rename to packages/stream_chat/test/src/core/models/serialization_test.dart index 9e64caad..605b5b58 100644 --- a/packages/stream_chat/test/src/models/serialization_test.dart +++ b/packages/stream_chat/test/src/core/models/serialization_test.dart @@ -1,5 +1,5 @@ import 'package:test/test.dart'; -import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/src/core/util/serializer.dart'; void main() { group('src/models/serialization', () { @@ -9,7 +9,7 @@ void main() { 'prop2': 123, 'prop3': true, }; - final result = Serialization.moveToExtraDataFromRoot(json, [ + final result = Serializer.moveToExtraDataFromRoot(json, [ 'prop1', 'prop2', ]); @@ -30,7 +30,7 @@ void main() { }); test('should have empty extraData', () { - final result = Serialization.moveToExtraDataFromRoot({ + final result = Serializer.moveToExtraDataFromRoot({ 'prop1': 'test', 'prop2': 123, 'prop3': true, @@ -49,7 +49,7 @@ void main() { }); test('should return null', () { - final result = Serialization.moveToExtraDataFromRoot({}, [ + final result = Serializer.moveToExtraDataFromRoot({}, [ 'prop1', 'prop2', ]); diff --git a/packages/stream_chat/test/src/core/models/user_test.dart b/packages/stream_chat/test/src/core/models/user_test.dart new file mode 100644 index 00000000..ce488cab --- /dev/null +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -0,0 +1,46 @@ +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + group('src/models/user', () { + test('should parse json correctly', () { + final user = User.fromJson(jsonFixture('user.json')); + expect(user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + expect(user.name, 'John'); + }); + + test('should serialize to json correctly', () { + final user = User( + id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', + role: 'abc', + ); + + expect(user.toJson(), { + 'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', + }); + }); + + test('copyWith', () { + final user = User.fromJson(jsonFixture('user.json')); + var newUser = user.copyWith(); + + expect(newUser.id, user.id); + expect(newUser.role, user.role); + expect(newUser.name, user.name); + + newUser = user.copyWith( + id: 'test', + role: 'test', + extraData: { + 'name': 'test', + }, + ); + + expect(newUser.id, 'test'); + expect(newUser.role, 'test'); + expect(newUser.name, 'test'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart new file mode 100644 index 00000000..110fb64e --- /dev/null +++ b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart @@ -0,0 +1,25 @@ +@TestOn('linux') +import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; +import 'package:test/test.dart'; + +void main() { + test('`.type` should return current platform', () { + final type = CurrentPlatform.type; + expect(type, PlatformType.linux); + }); + + test('`.name` should return current platform name', () { + final name = CurrentPlatform.name; + expect(name, 'linux'); + }); + + test('flags', () { + expect(CurrentPlatform.isWeb, isFalse); + expect(CurrentPlatform.isIos, isFalse); + expect(CurrentPlatform.isLinux, isTrue); + expect(CurrentPlatform.isAndroid, isFalse); + expect(CurrentPlatform.isMacOS, isFalse); + expect(CurrentPlatform.isWindows, isFalse); + expect(CurrentPlatform.isFuchsia, isFalse); + }); +} diff --git a/packages/stream_chat/test/src/core/util/extension_test.dart b/packages/stream_chat/test/src/core/util/extension_test.dart new file mode 100644 index 00000000..98f9f784 --- /dev/null +++ b/packages/stream_chat/test/src/core/util/extension_test.dart @@ -0,0 +1,48 @@ +import 'package:test/test.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; + +void main() { + test('`.withNullifyer` converts the type into non-nullable', () { + final items = ['A', 'B', null, 'D']; + expect(items, isA>()); + expect(items.length, 4); + + final nullifiedItems = items.withNullifyer; + expect(nullifiedItems, isA>()); + expect(nullifiedItems.length, 3); + }); + + test('`.nullProtected should remove all the null keys, value`', () { + final map = {'name': 'sahil', 'age': null, null: 'India'}; + expect(map, isA>()); + expect(map.length, 3); + + final nullProtectedMap = map.nullProtected; + expect(nullProtectedMap, isA>()); + expect(nullProtectedMap.length, 1); + }); + + group('mimeType', () { + test('should return null if `String` is not a filename', () { + const fileName = 'not-a-file-name'; + final mimeType = fileName.mimeType; + expect(mimeType, isNull); + }); + + test('should return mimeType if string is a filename', () { + const fileName = 'dummyFileName.jpeg'; + final mimeType = fileName.mimeType; + expect(mimeType, isNotNull); + expect(mimeType!.type, 'image'); + expect(mimeType.subtype, 'jpeg'); + }); + + test('should return `image/heic` if ends with `heic`', () { + const fileName = 'dummyFileName.heic'; + final mimeType = fileName.mimeType; + expect(mimeType, isNotNull); + expect(mimeType!.type, 'image'); + expect(mimeType.subtype, 'heic'); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/util/serializer_test.dart b/packages/stream_chat/test/src/core/util/serializer_test.dart new file mode 100644 index 00000000..fbbb96a2 --- /dev/null +++ b/packages/stream_chat/test/src/core/util/serializer_test.dart @@ -0,0 +1,43 @@ +import 'package:stream_chat/src/core/util/serializer.dart'; +import 'package:test/test.dart'; + +void main() { + group('Serializer', () { + test('moveKeysToMapInPlace', () { + final serializer = Serializer.moveToExtraDataFromRoot( + { + 'test': 'test', + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + }, + ['test'], + ); + expect(serializer, { + 'test': 'test', + 'extra_data': { + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + } + }); + }); + + test('moveKeysToMapInPlace', () { + final serializer = Serializer.moveFromExtraDataToRoot({ + 'test': 'test', + 'extra_data': { + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + } + }); + expect(serializer, { + 'test': 'test', + 'name': 'Sahil', + 'age': 22, + 'country': 'India', + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/util/utils_test.dart b/packages/stream_chat/test/src/core/util/utils_test.dart new file mode 100644 index 00000000..5d4c272f --- /dev/null +++ b/packages/stream_chat/test/src/core/util/utils_test.dart @@ -0,0 +1,15 @@ +import 'package:stream_chat/src/core/util/utils.dart'; +import 'package:test/test.dart'; + +void main() { + test('should generate a `randomId` of length 33', () { + final id = randomId(size: 33); + expect(id.length, 33); + }); + + test('should `generateHash` for the passed objects', () { + final objects = ['Sahil', 23, 'Flutter Engineer', 'India']; + final hash = generateHash(objects); + expect(hash, 'WyJTYWhpbCIsMjMsIkZsdXR0ZXIgRW5naW5lZXIiLCJJbmRpYSJd'); + }); +} diff --git a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart new file mode 100644 index 00000000..0ab7695f --- /dev/null +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -0,0 +1,191 @@ +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/member.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; +import 'package:stream_chat/src/core/models/read.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; +import 'package:test/test.dart'; + +class TestPersistenceClient extends ChatPersistenceClient { + @override + Future connect(String userId) => throw UnimplementedError(); + + @override + Future deleteChannels(List cids) => throw UnimplementedError(); + + @override + Future deleteMembersByCids(List cids) => Future.value(); + + @override + Future deleteMessageByCids(List cids) => Future.value(); + + @override + Future deleteMessageByIds(List messageIds) => Future.value(); + + @override + Future deletePinnedMessageByCids(List cids) => Future.value(); + + @override + Future deletePinnedMessageByIds(List messageIds) => + Future.value(); + + @override + Future deleteReactionsByMessageId(List messageIds) => + Future.value(); + + @override + Future disconnect({bool flush = false}) => throw UnimplementedError(); + + @override + Future getChannelByCid(String cid) async => + ChannelModel(cid: cid); + + @override + Future> getChannelCids() => throw UnimplementedError(); + + @override + Future> getChannelStates( + {Filter? filter, + List>? sort, + PaginationParams? paginationParams}) => + throw UnimplementedError(); + + @override + Future>> getChannelThreads(String cid) => + throw UnimplementedError(); + + @override + Future getConnectionInfo() => throw UnimplementedError(); + + @override + Future getLastSyncAt() => throw UnimplementedError(); + + @override + Future> getMembersByCid(String cid) async => []; + + @override + Future> getMessagesByCid(String cid, + {PaginationParams? messagePagination}) async => + []; + + @override + Future> getPinnedMessagesByCid(String cid, + {PaginationParams? messagePagination}) async => + []; + + @override + Future> getReadsByCid(String cid) async => []; + + @override + Future> getReplies(String parentId, + {PaginationParams? options}) => + throw UnimplementedError(); + + @override + Future updateChannelQueries(Filter? filter, List cids, + {bool clearQueryCache = false}) => + throw UnimplementedError(); + + @override + Future updateChannels(List channels) => Future.value(); + + @override + Future updateConnectionInfo(Event event) => throw UnimplementedError(); + + @override + Future updateLastSyncAt(DateTime lastSyncAt) => + throw UnimplementedError(); + + @override + Future updateMembers(String cid, List members) => + Future.value(); + + @override + Future updateMessages(String cid, List messages) => + Future.value(); + + @override + Future updatePinnedMessages(String cid, List messages) => + Future.value(); + + @override + Future updateReactions(List reactions) => Future.value(); + + @override + Future updateReads(String cid, List reads) => Future.value(); + + @override + Future updateUsers(List users) => Future.value(); +} + +void main() { + group('chatPersistenceClient', () { + final persistenceClient = TestPersistenceClient(); + + test('deleteMessageById', () { + const messageId = 'message-id'; + persistenceClient.deleteMessageById(messageId); + }); + + test('deleteMessageByCid', () { + const messageId = 'message-id'; + persistenceClient.deleteMessageByCid(messageId); + }); + + test('deletePinnedMessageById', () { + const messageId = 'message-id'; + persistenceClient.deletePinnedMessageById(messageId); + }); + + test('deletePinnedMessageByCid', () { + const messageId = 'message-id'; + persistenceClient.deletePinnedMessageByCid(messageId); + }); + + test('getChannelStateByCid', () async { + const cid = 'test:cid'; + final channelState = await persistenceClient.getChannelStateByCid(cid); + expect(channelState, isNotNull); + }); + + test('updateChannelState', () async { + const cid = 'test:cid'; + final channelState = ChannelState(); + persistenceClient.updateChannelState(channelState); + }); + + test('updateChannelStates', () async { + const cid = 'test:cid'; + final user = User(id: 'test-user-id'); + final channelState = ChannelState( + channel: ChannelModel(cid: cid, createdBy: user), + messages: [ + Message( + id: 'test-message', + text: 'test-message', + user: user, + ownReactions: [Reaction(type: 'test', user: user)], + latestReactions: [Reaction(type: 'test', user: user)], + ) + ], + pinnedMessages: [ + Message( + id: 'test-message', + text: 'test-message', + user: user, + ownReactions: [Reaction(type: 'test', user: user)], + latestReactions: [Reaction(type: 'test', user: user)], + ) + ], + read: [Read(lastRead: DateTime.now(), user: user)], + members: [Member(user: user)], + ); + persistenceClient.updateChannelStates([channelState]); + }); + }); +} diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart new file mode 100644 index 00000000..15d5991c --- /dev/null +++ b/packages/stream_chat/test/src/fakes.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:stream_chat/src/core/api/stream_chat_api.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/src/core/http/token.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'mocks.dart'; + +class FakeTokenManager extends Fake implements TokenManager { + final token = Token.development('test-user-id'); + + @override + bool get isStatic => true; + + @override + String? get userId => token.userId; + + @override + Future loadToken({bool refresh = false}) async => token; + + @override + Future setTokenOrProvider( + String userId, { + Token? token, + TokenProvider? provider, + }) async => + this.token; + + @override + void reset() {} +} + +class FakeMultiPartFile extends Fake implements MultipartFile {} + +class FakeChatApi extends Fake implements StreamChatApi { + UserApi? _user; + + @override + UserApi get user => _user ??= MockUserApi(); + + GuestApi? _guest; + + @override + GuestApi get guest => _guest ??= MockGuestApi(); + + MessageApi? _message; + + @override + MessageApi get message => _message ??= MockMessageApi(); + + ChannelApi? _channel; + + @override + ChannelApi get channel => _channel ??= MockChannelApi(); + + DeviceApi? _device; + + @override + DeviceApi get device => _device ??= MockDeviceApi(); + + ModerationApi? _moderation; + + @override + ModerationApi get moderation => _moderation ??= MockModerationApi(); + + GeneralApi? _general; + + @override + GeneralApi get general => _general ??= MockGeneralApi(); + + AttachmentFileUploader? _fileUploader; + + @override + AttachmentFileUploader get fileUploader => + _fileUploader ??= MockAttachmentFileUploader(); +} + +class FakeClientState extends Fake implements ClientState { + @override + OwnUser? get user => OwnUser(id: 'test-user-id'); + + @override + int totalUnreadCount = 0; +} + +class FakeMessage extends Fake implements Message {} + +class FakeAttachmentFile extends Fake implements AttachmentFile {} + +class FakeEvent extends Fake implements Event {} + +class FakeUser extends Fake implements User {} + +class FakeWebSocket extends Fake implements WebSocket { + BehaviorSubject? _connectionStatusController; + + BehaviorSubject get connectionStatusController => + _connectionStatusController ??= + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set connectionStatus(ConnectionStatus value) { + connectionStatusController.add(value); + } + + @override + ConnectionStatus get connectionStatus => connectionStatusController.value; + + @override + Stream get connectionStatusStream => + connectionStatusController.stream; + + @override + Completer? connectionCompleter; + + @override + Future connect(User user) async { + connectionStatus = ConnectionStatus.connecting; + final event = Event( + type: EventType.healthCheck, + connectionId: 'fake-connection-id', + me: OwnUser.fromUser(user), + ); + connectionCompleter = Completer()..complete(event); + connectionStatus = ConnectionStatus.connected; + return connectionCompleter!.future; + } + + @override + void disconnect() { + connectionStatus = ConnectionStatus.disconnected; + connectionCompleter = null; + _connectionStatusController?.close(); + _connectionStatusController = null; + } +} + +class FakeWebSocketWithConnectionError extends Fake implements WebSocket { + BehaviorSubject? _connectionStatusController; + + BehaviorSubject get connectionStatusController => + _connectionStatusController ??= + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set connectionStatus(ConnectionStatus value) { + connectionStatusController.add(value); + } + + @override + ConnectionStatus get connectionStatus => connectionStatusController.value; + + @override + Stream get connectionStatusStream => + connectionStatusController.stream; + + @override + Completer? connectionCompleter; + + @override + Future connect(User user) async { + connectionStatus = ConnectionStatus.connecting; + const error = StreamWebSocketError('Error Connecting'); + connectionCompleter = Completer()..completeError(error); + return connectionCompleter!.future; + } + + @override + void disconnect() { + connectionStatus = ConnectionStatus.disconnected; + connectionCompleter = null; + _connectionStatusController?.close(); + _connectionStatusController = null; + } +} + +class FakeChannelState extends Fake implements ChannelState {} diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart new file mode 100644 index 00000000..27cfa43d --- /dev/null +++ b/packages/stream_chat/test/src/matchers.dart @@ -0,0 +1,133 @@ +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart' show MultipartFile; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +Matcher isSameMultipartFileAs(MultipartFile targetFile) => + _IsSameMultipartFileAs(targetFile: targetFile); + +class _IsSameMultipartFileAs extends Matcher { + const _IsSameMultipartFileAs({required this.targetFile}); + + final MultipartFile targetFile; + + @override + Description describe(Description description) => + description.add('is same multipartFile as $targetFile'); + + @override + bool matches(covariant MultipartFile file, Map matchState) => + file.length == targetFile.length; +} + +Matcher isSameEventAs(Event targetEvent) => + _IsSameEventAs(targetEvent: targetEvent); + +class _IsSameEventAs extends Matcher { + const _IsSameEventAs({required this.targetEvent}); + + final Event targetEvent; + + @override + Description describe(Description description) => + description.add('is same event as $targetEvent'); + + @override + bool matches(covariant Event event, Map matchState) => + event.type == targetEvent.type; +} + +Matcher isSameMessageAs( + Message targetMessage, { + bool matchText = false, + bool matchReactions = false, + bool matchSendingStatus = false, +}) => + _IsSameMessageAs( + targetMessage: targetMessage, + matchText: matchText, + matchReactions: matchReactions, + matchSendingStatus: matchSendingStatus, + ); + +class _IsSameMessageAs extends Matcher { + const _IsSameMessageAs({ + required this.targetMessage, + this.matchText = false, + this.matchReactions = false, + this.matchSendingStatus = false, + }); + + final Message targetMessage; + final bool matchText; + final bool matchReactions; + final bool matchSendingStatus; + + @override + Description describe(Description description) => + description.add('is same message as $targetMessage'); + + @override + bool matches(covariant Message message, Map matchState) { + var matches = message.id == targetMessage.id; + if (matchText) { + matches &= message.text == targetMessage.text; + } + if (matchSendingStatus) { + matches &= message.status == targetMessage.status; + } + if (matchReactions) { + matches &= const ListEquality().equals( + message.ownReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList(), + targetMessage.ownReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList()); + matches &= const ListEquality().equals( + message.latestReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList(), + targetMessage.latestReactions + ?.map((it) => '${it.type}-${it.messageId}') + .toList()); + } + return matches; + } +} + +Matcher isSameUserAs(User targetUser) => _IsSameUserAs(targetUser: targetUser); + +class _IsSameUserAs extends Matcher { + const _IsSameUserAs({required this.targetUser}); + + final User targetUser; + + @override + Description describe(Description description) => + description.add('is same user as $targetUser'); + + @override + bool matches(covariant User user, Map matchState) => user.id == targetUser.id; +} + +Matcher isCorrectChannelFor(ChannelState channelState) => + _IsCorrectChannelFor(channelState: channelState); + +class _IsCorrectChannelFor extends Matcher { + const _IsCorrectChannelFor({required this.channelState}); + + final ChannelState channelState; + + @override + Description describe(Description description) => + description.add('is correct channel for $channelState'); + + @override + bool matches(covariant Channel channel, Map matchState) => + channel.cid == channelState.channel?.cid; +} diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart new file mode 100644 index 00000000..44078519 --- /dev/null +++ b/packages/stream_chat/test/src/mocks.dart @@ -0,0 +1,112 @@ +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/client.dart'; +import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; +import 'package:stream_chat/src/core/api/channel_api.dart'; +import 'package:stream_chat/src/core/api/device_api.dart'; +import 'package:stream_chat/src/core/api/general_api.dart'; +import 'package:stream_chat/src/core/api/guest_api.dart'; +import 'package:stream_chat/src/core/api/message_api.dart'; +import 'package:stream_chat/src/core/api/moderation_api.dart'; +import 'package:stream_chat/src/core/api/user_api.dart'; +import 'package:stream_chat/src/core/http/connection_id_manager.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/src/core/models/channel_config.dart'; +import 'package:stream_chat/src/db/chat_persistence_client.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class MockWebSocketChannel extends Mock implements WebSocketChannel {} + +class MockWebSocketSink extends Mock implements WebSocketSink {} + +class MockDio extends Mock implements Dio { + BaseOptions? _options; + + @override + BaseOptions get options => _options ??= BaseOptions(); + + Interceptors? _interceptors; + + @override + Interceptors get interceptors => _interceptors ??= Interceptors(); +} + +class MockLogger extends Mock implements Logger { + @override + Level get level => Level.ALL; +} + +class MockHttpClient extends Mock implements StreamHttpClient {} + +class MockTokenManager extends Mock implements TokenManager {} + +class MockConnectionIdManager extends Mock implements ConnectionIdManager {} + +class MockUserApi extends Mock implements UserApi {} + +class MockGuestApi extends Mock implements GuestApi {} + +class MockMessageApi extends Mock implements MessageApi {} + +class MockChannelApi extends Mock implements ChannelApi {} + +class MockDeviceApi extends Mock implements DeviceApi {} + +class MockModerationApi extends Mock implements ModerationApi {} + +class MockGeneralApi extends Mock implements GeneralApi {} + +class MockAttachmentFileUploader extends Mock + implements AttachmentFileUploader {} + +class MockPersistenceClient extends Mock implements ChatPersistenceClient { + @override + Future connect(String userId) => Future.value(); + + @override + Future disconnect({bool flush = false}) => Future.value(); +} + +class MockStreamChatClient extends Mock implements StreamChatClient { + @override + bool get persistenceEnabled => false; +} + +class MockStreamChatClientWithPersistence extends Mock + implements StreamChatClient { + ChatPersistenceClient? _persistenceClient; + + @override + ChatPersistenceClient get chatPersistenceClient => + _persistenceClient ??= MockPersistenceClient(); + + @override + bool get persistenceEnabled => true; +} + +class MockChannelConfig extends Mock implements ChannelConfig {} + +class MockRetryQueueChannel extends Mock implements Channel { + final channelId = 'test-channel-id'; + final channelType = 'test-channel-type'; + + @override + String? get id => channelId; + + @override + String get type => channelType; + + @override + String? get cid => '$channelType:$channelId'; + + StreamChatClient? _client; + + @override + StreamChatClient get client => _client ??= MockStreamChatClient(); +} + +class MockWebSocket extends Mock implements WebSocket {} diff --git a/packages/stream_chat/test/src/models/channel_state_test.dart b/packages/stream_chat/test/src/models/channel_state_test.dart deleted file mode 100644 index df078583..00000000 --- a/packages/stream_chat/test/src/models/channel_state_test.dart +++ /dev/null @@ -1,1319 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/channel_config.dart'; -import 'package:stream_chat/src/models/channel_state.dart'; -import 'package:stream_chat/src/models/command.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; - -void main() { - group('src/models/channel_state', () { - const jsonExample = ''' - { - "channel": { - "id": "dev", - "type": "team", - "cid": "team:dev", - "last_message_at": "2020-01-30T13:43:41.062362Z", - "created_at": "2019-04-03T18:43:33.213373Z", - "updated_at": "2019-04-03T18:43:33.213374Z", - "team": "test", - "created_by": { - "id": "guido", - "role": "user", - "created_at": "2019-04-03T18:43:33.201036Z", - "updated_at": "2019-04-03T18:43:33.204713Z", - "banned": false, - "online": false, - "name": "Guido" - }, - "frozen": true, - "config": { - "created_at": "2019-11-07T22:29:26.776526Z", - "updated_at": "2019-11-07T22:29:48.286746Z", - "name": "team", - "typing_events": true, - "read_events": true, - "connect_events": true, - "search": true, - "reactions": true, - "replies": true, - "mutes": true, - "uploads": true, - "url_enrichment": true, - "message_retention": "infinite", - "max_message_length": 5000, - "automod": "disabled", - "automod_behavior": "flag", - "commands": [ - { - "name": "giphy", - "description": "Post a random gif to the channel", - "args": "[text]", - "set": "fun_set" - } - ] - }, - "name": "#dev", - "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", - "example": 1 - }, - "messages": [ - { - "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", - "text": "fasdfa", - "type": "regular", - "status": "SENT", - "silent": false, - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:23:02.843948Z", - "updated_at": "2020-01-29T03:23:02.843949Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", - "text": "test message", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:23:07.981091Z", - "updated_at": "2020-01-29T03:23:07.981091Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", - "text": "test message", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:23:11.568022Z", - "updated_at": "2020-01-29T03:23:11.568022Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", - "text": "asdfadf", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:32:57.403566Z", - "updated_at": "2020-01-29T03:32:57.403566Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", - "text": "test", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:33:35.294802Z", - "updated_at": "2020-01-29T03:33:35.294802Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", - "text": "hi", - "type": "regular", - "user": { - "id": "withered-cell-0", - "role": "user", - "created_at": "2020-01-29T03:34:01.698106Z", - "updated_at": "2020-01-29T03:34:01.708808Z", - "last_active": "2020-01-29T03:34:01.70353Z", - "banned": false, - "online": false, - "name": "Withered cell", - "image": "https://getstream.io/random_svg/?name=Withered+cell" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:34:27.393296Z", - "updated_at": "2020-01-29T03:34:27.393296Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", - "text": "fantastic", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:34:37.638376Z", - "updated_at": "2020-01-29T03:34:37.638376Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", - "text": "nice to meet you", - "type": "regular", - "user": { - "id": "withered-cell-0", - "role": "user", - "created_at": "2020-01-29T03:34:01.698106Z", - "updated_at": "2020-01-29T03:34:01.708808Z", - "last_active": "2020-01-29T03:34:01.70353Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Withered+cell", - "name": "Withered cell" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:04.301566Z", - "updated_at": "2020-01-29T03:35:04.301566Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", - "text": "hey", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:24.939084Z", - "updated_at": "2020-01-29T03:35:24.939085Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", - "text": "hello, everyone", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "name": "Dry meadow", - "image": "https://getstream.io/random_svg/?name=Dry+meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:33.101566Z", - "updated_at": "2020-01-29T03:35:33.101566Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", - "text": "who is there?", - "type": "regular", - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "name": "Dry meadow", - "image": "https://getstream.io/random_svg/?name=Dry+meadow" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T03:35:45.458685Z", - "updated_at": "2020-01-29T03:35:45.458685Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", - "text": "í•˜ė´", - "type": "regular", - "user": { - "id": "icy-recipe-7", - "role": "user", - "created_at": "2020-01-21T11:36:22.284503Z", - "updated_at": "2020-01-29T07:01:59.69882Z", - "last_active": "2020-01-29T07:01:59.693378Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Icy+recipe", - "name": "Icy recipe" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T07:02:11.535395Z", - "updated_at": "2020-01-29T07:02:11.535395Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", - "text": "what are you doing?", - "type": "regular", - "user": { - "id": "icy-recipe-7", - "role": "user", - "created_at": "2020-01-21T11:36:22.284503Z", - "updated_at": "2020-01-29T07:01:59.69882Z", - "last_active": "2020-01-29T07:01:59.693378Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Icy+recipe", - "name": "Icy recipe" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T07:02:22.485136Z", - "updated_at": "2020-01-29T07:02:22.485136Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", - "text": "👍", - "type": "regular", - "user": { - "id": "throbbing-boat-5", - "role": "user", - "created_at": "2019-07-30T06:29:53.060413Z", - "updated_at": "2020-01-29T14:11:27.80176Z", - "last_active": "2020-01-29T14:11:27.7963Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Throbbing+boat", - "name": "Throbbing boat" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T14:12:04.688552Z", - "updated_at": "2020-01-29T14:12:04.688552Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", - "text": "sdasas", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:36.011315Z", - "updated_at": "2020-01-29T15:29:36.011316Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", - "text": "cjshsa", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:41.677819Z", - "updated_at": "2020-01-29T15:29:41.677819Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", - "text": "nhisagdhsadz", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:43.354177Z", - "updated_at": "2020-01-29T15:29:43.354177Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", - "text": "hvadhsahzd", - "type": "regular", - "user": { - "id": "snowy-credit-3", - "role": "user", - "created_at": "2020-01-29T15:29:03.693312Z", - "updated_at": "2020-01-29T15:29:03.702648Z", - "last_active": "2020-01-29T15:29:03.696144Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Snowy+credit", - "name": "Snowy credit" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T15:29:44.754713Z", - "updated_at": "2020-01-29T15:29:44.754713Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", - "text": "hello", - "type": "regular", - "user": { - "id": "divine-glade-9", - "role": "user", - "created_at": "2020-01-29T17:02:18.312524Z", - "updated_at": "2020-01-29T17:02:18.320187Z", - "last_active": "2020-01-29T17:02:18.315074Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Divine+glade", - "name": "Divine glade" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T17:02:36.933852Z", - "updated_at": "2020-01-29T17:02:36.933852Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", - "text": "hello", - "type": "regular", - "user": { - "id": "red-firefly-9", - "role": "user", - "created_at": "2019-08-02T18:56:39.366516Z", - "updated_at": "2020-01-29T22:13:50.491769Z", - "last_active": "2020-01-29T22:13:50.450215Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Red+firefly", - "name": "Red firefly" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-29T22:14:08.54062Z", - "updated_at": "2020-01-29T22:14:08.54062Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", - "text": "hello", - "type": "regular", - "user": { - "id": "bitter-glade-2", - "role": "user", - "created_at": "2020-01-30T13:08:56.190678Z", - "updated_at": "2020-01-30T13:08:56.200333Z", - "last_active": "2020-01-30T13:08:56.193882Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Bitter+glade", - "name": "Bitter glade" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:11:37.191293Z", - "updated_at": "2020-01-30T13:11:37.191293Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", - "text": "http://jaeger.ui.gtstrm.com/", - "type": "regular", - "user": { - "id": "morning-sea-1", - "role": "user", - "created_at": "2019-07-22T09:19:07.505207Z", - "updated_at": "2020-01-30T13:33:05.831856Z", - "last_active": "2020-01-30T13:33:05.825369Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Morning+sea", - "name": "Morning sea" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:33:16.853116Z", - "updated_at": "2020-01-30T13:33:16.853116Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", - "text": "hi", - "type": "regular", - "user": { - "id": "ancient-salad-0", - "role": "user", - "created_at": "2020-01-30T13:34:29.286813Z", - "updated_at": "2020-01-30T13:34:29.296196Z", - "last_active": "2020-01-30T13:34:29.289964Z", - "banned": false, - "online": true, - "image": "https://getstream.io/random_svg/?name=Ancient+salad", - "name": "Ancient salad" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:36:52.749731Z", - "updated_at": "2020-01-30T13:36:52.749732Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", - "text": "hi", - "type": "regular", - "user": { - "id": "ancient-salad-0", - "role": "user", - "created_at": "2020-01-30T13:34:29.286813Z", - "updated_at": "2020-01-30T13:34:29.296196Z", - "last_active": "2020-01-30T13:34:29.289964Z", - "banned": false, - "online": true, - "image": "https://getstream.io/random_svg/?name=Ancient+salad", - "name": "Ancient salad" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:37:41.631056Z", - "updated_at": "2020-01-30T13:37:41.631056Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", - "text": "😃", - "type": "regular", - "user": { - "id": "proud-sea-7", - "role": "user", - "created_at": "2020-01-30T13:43:03.903006Z", - "updated_at": "2020-01-30T13:43:03.912307Z", - "last_active": "2020-01-30T13:43:03.906236Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Proud+sea", - "name": "Proud sea" - }, - "attachments": [], - "latest_reactions": [], - "own_reactions": [], - "reaction_counts": {}, - "reaction_scores": {}, - "reply_count": 0, - "created_at": "2020-01-30T13:43:41.062362Z", - "updated_at": "2020-01-30T13:43:41.062362Z", - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - } - ], - "watcher_count": 5, - "members": [] - }'''; - - test('should parse json correctly', () { - final channelState = ChannelState.fromJson(json.decode(jsonExample)); - expect(channelState.channel?.cid, 'team:dev'); - expect(channelState.channel?.id, 'dev'); - expect(channelState.channel?.team, 'test'); - expect(channelState.channel?.type, 'team'); - expect(channelState.channel?.config, isA()); - expect(channelState.channel?.config, isNotNull); - expect(channelState.channel?.config.commands, hasLength(1)); - expect(channelState.channel?.config.commands[0], isA()); - expect(channelState.channel?.lastMessageAt, - DateTime.parse('2020-01-30T13:43:41.062362Z')); - expect(channelState.channel?.createdAt, - DateTime.parse('2019-04-03T18:43:33.213373Z')); - expect(channelState.channel?.updatedAt, - DateTime.parse('2019-04-03T18:43:33.213374Z')); - expect(channelState.channel?.createdBy, isA()); - expect(channelState.channel?.frozen, true); - expect(channelState.channel?.extraData['example'], 1); - expect(channelState.channel?.extraData['name'], '#dev'); - expect( - channelState.channel?.extraData['image'], - 'https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png', - ); - expect(channelState.messages, hasLength(25)); - expect(channelState.messages[0], isA()); - expect(channelState.messages[0], isNotNull); - expect( - channelState.messages[0].createdAt, - DateTime.parse('2020-01-29T03:23:02.843948Z'), - ); - expect(channelState.messages[0].user, isA()); - expect(channelState.watcherCount, 5); - }); - - test('should serialize to json correctly', () { - const toJsonExample = ''' - { - "channel": { - "id": "dev", - "type": "team", - "frozen": true, - "name": "#dev", - "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", - "example": 1 - }, - "watchers": [], - "read": [], - "messages": [ - { - "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", - "text": "fasdfa", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", - "text": "test message", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", - "text": "test message", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", - "text": "asdfadf", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", - "text": "test", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", - "text": "hi", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", - "text": "fantastic", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", - "text": "nice to meet you", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", - "text": "hey", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", - "text": "hello, everyone", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", - "text": "who is there?", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", - "text": "í•˜ė´", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", - "text": "what are you doing?", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", - "text": "👍", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", - "text": "sdasas", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", - "text": "cjshsa", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", - "text": "nhisagdhsadz", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", - "text": "hvadhsahzd", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", - "text": "hello", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", - "text": "hello", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", - "text": "hello", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", - "text": "http://jaeger.ui.gtstrm.com/", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", - "text": "hi", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", - "text": "hi", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - }, - { - "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", - "text": "😃", - "attachments": [], - "parent_id": null, - "quoted_message": null, - "quoted_message_id": null, - "show_in_channel": null, - "mentioned_users": [], - "status": "SENT", - "silent": false, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null - } - ], - "pinned_messages": [], - "members": [], - "watcher_count": 5 - } - '''; - final j = jsonDecode(jsonExample); - final channelState = ChannelState( - channel: ChannelModel.fromJson(j['channel']), - members: [], - messages: - (j['messages'] as List).map((m) => Message.fromJson(m)).toList(), - read: [], - watcherCount: 5, - pinnedMessages: [], - watchers: [], - ); - - expect( - channelState.toJson(), - jsonDecode(toJsonExample), - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/event_test.dart b/packages/stream_chat/test/src/models/event_test.dart deleted file mode 100644 index 96efa844..00000000 --- a/packages/stream_chat/test/src/models/event_test.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/event.dart'; -import 'package:stream_chat/src/models/own_user.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:test/test.dart'; - -void main() { - group('src/models/event', () { - const jsonExample = ''' - { - "type": "type", - "cid": "cid", - "connection_id": "connectionId", - "created_at": "2019-04-03T18:43:33.213374Z", - "me": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - }, - "parent_id": null, - "user": { - "id": "dry-meadow-0", - "role": "user", - "created_at": "2019-03-27T17:40:17.155892Z", - "updated_at": "2020-01-29T03:22:47.641589Z", - "last_active": "2020-01-29T03:22:47.63613Z", - "banned": false, - "online": false, - "image": "https://getstream.io/random_svg/?name=Dry+meadow", - "name": "Dry meadow" - } - } - '''; - - test('should parse json correctly', () { - final event = Event.fromJson(json.decode(jsonExample)); - expect(event.type, 'type'); - expect(event.cid, 'cid'); - expect(event.connectionId, 'connectionId'); - expect(event.createdAt, isA()); - expect(event.me, isA()); - expect(event.user, isA()); - expect(event.isLocal, false); - }); - - test('should serialize to json correctly', () { - final event = Event( - user: User(id: 'id'), - type: 'type', - cid: 'cid', - connectionId: 'connectionId', - createdAt: DateTime.parse('2020-01-29T03:22:47.63613Z'), - me: OwnUser(id: 'id2'), - totalUnreadCount: 1, - unreadChannels: 1, - online: true, - ); - - expect( - event.toJson(), - { - 'type': 'type', - 'cid': 'cid', - 'connection_id': 'connectionId', - 'created_at': '2020-01-29T03:22:47.636130Z', - 'me': {'id': 'id2'}, - 'user': {'id': 'id'}, - 'reaction': null, - 'message': null, - 'channel': null, - 'total_unread_count': 1, - 'unread_channels': 1, - 'online': true, - 'member': null, - 'channel_id': null, - 'channel_type': null, - 'parent_id': null, - 'is_local': true, - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/member_test.dart b/packages/stream_chat/test/src/models/member_test.dart deleted file mode 100644 index 25e1d78e..00000000 --- a/packages/stream_chat/test/src/models/member_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/member.dart'; -import 'package:stream_chat/src/models/user.dart'; - -void main() { - group('src/models/member', () { - const jsonExample = ''' - { - "user": { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "role": "user", - "created_at": "2020-01-28T22:17:30.826259Z", - "updated_at": "2020-01-28T22:17:31.101222Z", - "banned": false, - "online": false, - "name": "Robin Papa", - "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg" - }, - "role": "member", - "created_at": "2020-01-28T22:17:30.95443Z", - "updated_at": "2020-01-28T22:17:30.95443Z" - } - '''; - - test('should parse json correctly', () { - final member = Member.fromJson(json.decode(jsonExample)); - expect(member.user, isA()); - expect(member.role, 'member'); - expect(member.createdAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); - expect(member.updatedAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/message_test.dart b/packages/stream_chat/test/src/models/message_test.dart deleted file mode 100644 index 84ae616e..00000000 --- a/packages/stream_chat/test/src/models/message_test.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/attachment.dart'; -import 'package:stream_chat/src/models/message.dart'; -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:test/test.dart'; - -void main() { - group('src/models/message', () { - const jsonExample = r''' - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "type": "regular", - "silent": false, - "status": "SENT", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "attachments": [ - { - "type": "video", - "author_name": "GIPHY", - "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" - } - ], - "latest_reactions": [ - { - "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "user": { - "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", - "role": "user", - "created_at": "2020-01-28T22:17:30.83015Z", - "updated_at": "2020-01-28T22:17:31.19435Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/2.jpg", - "name": "Mia Denys" - }, - "type": "love", - "score": 1, - "created_at": "2020-01-28T22:17:31.128376Z", - "updated_at": "2020-01-28T22:17:31.128376Z" - } - ], - "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 - }, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null, - "reply_count": 0, - "created_at": "2020-01-28T22:17:31.107978Z", - "updated_at": "2020-01-28T22:17:31.130506Z", - "mentioned_users": [] - }'''; - - test('should parse json correctly', () { - final message = Message.fromJson(json.decode(jsonExample)); - expect(message.id, '4637f7e4-a06b-42db-ba5a-8d8270dd926f'); - expect(message.text, - 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA'); - expect(message.type, 'regular'); - expect(message.user, isA()); - expect(message.silent, isA()); - expect(message.attachments, isA>()); - expect(message.latestReactions, isA>()); - expect(message.ownReactions, isA>()); - expect(message.reactionCounts, {'love': 1}); - expect(message.reactionScores, {'love': 1}); - expect(message.createdAt, DateTime.parse('2020-01-28T22:17:31.107978Z')); - expect(message.updatedAt, DateTime.parse('2020-01-28T22:17:31.130506Z')); - expect(message.mentionedUsers, isA>()); - expect(message.pinned, false); - expect(message.pinnedAt, null); - expect(message.pinExpires, null); - expect(message.pinnedBy, null); - }); - - test('should serialize to json correctly', () { - final message = Message( - id: '4637f7e4-a06b-42db-ba5a-8d8270dd926f', - text: - 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA', - attachments: [ - Attachment.fromJson(const { - 'type': 'video', - 'author_name': 'GIPHY', - 'title': 'The Lion King Disney GIF - Find \u0026 Share on GIPHY', - 'title_link': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', - 'text': - '''Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.''', - 'image_url': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', - 'thumb_url': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', - 'asset_url': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4', - 'og_scrape_url': - 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA' - }) - ], - showInChannel: true, - parentId: 'parentId', - extraData: const {'hey': 'test'}, - ); - - expect( - message.toJson(), - json.decode(''' - { - "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", - "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "silent": false, - "attachments": [ - { - "type": "video", - "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "title": "The Lion King Disney GIF - Find & Share on GIPHY", - "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "text": "Discover & share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", - "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", - "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", - "author_name": "GIPHY", - "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", - "actions": [] - } - ], - "mentioned_users": [], - "parent_id": "parentId", - "quoted_message": null, - "quoted_message_id": null, - "pinned": false, - "pinned_at": null, - "pin_expires": null, - "pinned_by": null, - "show_in_channel": true, - "hey": "test" - } - '''), - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/reaction_test.dart b/packages/stream_chat/test/src/models/reaction_test.dart deleted file mode 100644 index 795e2967..00000000 --- a/packages/stream_chat/test/src/models/reaction_test.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/reaction.dart'; -import 'package:stream_chat/src/models/user.dart'; -import 'package:test/test.dart'; - -void main() { - group('src/models/reaction', () { - const jsonExample = ''' - { - "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", - "user_id": "2de0297c-f3f2-489d-b930-ef77342edccf", - "user": { - "id": "2de0297c-f3f2-489d-b930-ef77342edccf", - "role": "user", - "created_at": "2020-01-28T22:17:30.810011Z", - "updated_at": "2020-01-28T22:17:31.077195Z", - "banned": false, - "online": false, - "image": "https://randomuser.me/api/portraits/women/45.jpg", - "name": "Daisy Morgan" - }, - "type": "wow", - "score": 1, - "created_at": "2020-01-28T22:17:31.108742Z", - "updated_at": "2020-01-28T22:17:31.108742Z" - } - '''; - - test('should parse json correctly', () { - final reaction = Reaction.fromJson(json.decode(jsonExample)); - expect(reaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); - expect(reaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); - expect(reaction.type, 'wow'); - expect( - reaction.user?.toJson(), - User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: { - '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'); - expect(reaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); - }); - - test('should serialize to json correctly', () { - final reaction = Reaction( - 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: { - 'image': 'https://randomuser.me/api/portraits/women/45.jpg', - 'name': 'Daisy Morgan' - }), - userId: '2de0297c-f3f2-489d-b930-ef77342edccf', - extraData: {'bananas': 'yes'}, - score: 1, - ); - - expect( - reaction.toJson(), - { - 'message_id': '76cd8c82-b557-4e48-9d12-87995d3a0e04', - 'type': 'wow', - 'score': 1, - 'bananas': 'yes', - }, - ); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/read_test.dart b/packages/stream_chat/test/src/models/read_test.dart deleted file mode 100644 index 66efe809..00000000 --- a/packages/stream_chat/test/src/models/read_test.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; -import 'package:stream_chat/src/models/read.dart'; -import 'package:stream_chat/src/models/user.dart'; - -void main() { - group('src/models/read', () { - const jsonExample = ''' - { - "user": { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e" - }, - "last_read": "2020-01-28T22:17:30.966485504Z", - "unread_messages": 10 - } - '''; - - test('should parse json correctly', () { - final read = Read.fromJson(json.decode(jsonExample)); - expect(read.lastRead, DateTime.parse('2020-01-28T22:17:30.966485504Z')); - expect(read.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); - expect(read.unreadMessages, 10); - }); - - test('should serialize to json correctly', () { - final read = Read( - lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), - user: User(id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'), - unreadMessages: 10, - ); - - expect(read.toJson(), { - 'user': {'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'}, - 'last_read': '2020-01-28T22:17:30.966485Z', - 'unread_messages': 10, - }); - }); - }); -} diff --git a/packages/stream_chat/test/src/models/user_test.dart b/packages/stream_chat/test/src/models/user_test.dart deleted file mode 100644 index 8f56f7e1..00000000 --- a/packages/stream_chat/test/src/models/user_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/models/user.dart'; -import 'package:test/test.dart'; - -void main() { - group('src/models/user', () { - const jsonExample = ''' - { - "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", - "role": "test-role" - } - '''; - - test('should parse json correctly', () { - final user = User.fromJson(json.decode(jsonExample)); - expect(user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); - }); - - test('should serialize to json correctly', () { - final user = User( - id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', - role: 'abc', - ); - - expect(user.toJson(), { - 'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', - }); - }); - }); -} diff --git a/packages/stream_chat/test/src/utils.dart b/packages/stream_chat/test/src/utils.dart new file mode 100644 index 00000000..768d2fe2 --- /dev/null +++ b/packages/stream_chat/test/src/utils.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +import 'dart:io'; + +String fixture(String name) { + final dir = currentDirectory.path; + return File('$dir/test/fixtures/$name').readAsStringSync(); +} + +File assetFile(String name) { + final dir = currentDirectory.path; + return File('$dir/test/assets/$name'); +} + +Map jsonFixture(String name) => json.decode(fixture(name)); + +// https://github.com/flutter/flutter/issues/20907 +Directory get currentDirectory { + var directory = Directory.current; + if (directory.path.endsWith('/test')) { + directory = directory.parent; + } + return directory; +} + +// Extension function to convert int into durations +extension IntX on num { + Duration toDuration() => Duration(milliseconds: toInt()); +} + +// Top level util function to delay the code execution +Future delay(num milliseconds) => + Future.delayed(Duration(milliseconds: milliseconds.toInt())); diff --git a/packages/stream_chat/test/src/ws/timer_helper_test.dart b/packages/stream_chat/test/src/ws/timer_helper_test.dart new file mode 100644 index 00000000..ecafd6d2 --- /dev/null +++ b/packages/stream_chat/test/src/ws/timer_helper_test.dart @@ -0,0 +1,98 @@ +import 'package:stream_chat/src/ws/timer_helper.dart'; +import 'package:test/test.dart'; + +void main() { + late TimerHelper timerHelper; + + setUp(() { + timerHelper = TimerHelper(); + }); + + tearDown(() { + timerHelper.cancelAllTimers(); + }); + + test('setTimer', () async { + expect(timerHelper.hasTimers, isFalse); + + var count = 0; + void callback() => count += 1; + + timerHelper.setTimer( + const Duration(milliseconds: 500), + callback, + ); + + expect(count, 0); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 1); + + expect(timerHelper.hasTimers, isTrue); + }); + + test('setImmediateTimer', () async { + var count = 0; + void callback() => count += 1; + + timerHelper.setTimer( + const Duration(milliseconds: 500), + callback, + immediate: true, + ); + + expect(count, 1); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 2); + }); + + test('setPeriodicTimer', () async { + expect(timerHelper.hasTimers, isFalse); + var count = 0; + void callback() => count += 1; + + timerHelper.setTimer( + const Duration(milliseconds: 500), + callback, + ); + + expect(count, 0); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 1); + + expect(timerHelper.hasTimers, isTrue); + }); + + test('setImmediatePeriodicTimer', () async { + var count = 0; + void callback(_) => count += 1; + + timerHelper.setPeriodicTimer( + const Duration(milliseconds: 500), + callback, + immediate: true, + ); + + expect(count, 1); + await Future.delayed(const Duration(milliseconds: 500)); + expect(count, 2); + }); + + test('cancelTimer', () { + expect(timerHelper.hasTimers, isFalse); + final id = timerHelper.setTimer(const Duration(seconds: 3), () {}); + expect(timerHelper.hasTimers, isTrue); + timerHelper.cancelTimer(id); + expect(timerHelper.hasTimers, isFalse); + }); + + test('cancelAllTimers', () { + expect(timerHelper.hasTimers, isFalse); + timerHelper + ..setTimer(const Duration(seconds: 3), () {}) + ..setTimer(const Duration(seconds: 6), () {}) + ..setTimer(const Duration(seconds: 9), () {}); + expect(timerHelper.hasTimers, isTrue); + timerHelper.cancelAllTimers(); + expect(timerHelper.hasTimers, isFalse); + }); +} diff --git a/packages/stream_chat/test/src/ws/websocket_test.dart b/packages/stream_chat/test/src/ws/websocket_test.dart new file mode 100644 index 00000000..94033bf0 --- /dev/null +++ b/packages/stream_chat/test/src/ws/websocket_test.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:stream_chat/src/core/http/token_manager.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/ws/websocket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + late TokenManager tokenManager; + late WebSocketChannel webSocketChannel; + late WebSocketSink webSocketSink; + late WebSocket webSocket; + + setUp(() { + tokenManager = FakeTokenManager(); + webSocketChannel = MockWebSocketChannel(); + + WebSocketChannel channelProvider( + Uri uri, { + Iterable? protocols, + }) => + webSocketChannel; + + webSocket = WebSocket( + apiKey: 'api-key', + baseUrl: 'base-url', + tokenManager: tokenManager, + webSocketChannelProvider: channelProvider, + ); + + webSocketSink = MockWebSocketSink(); + when(() => webSocketChannel.sink).thenReturn(webSocketSink); + + var webSocketController = StreamController.broadcast(); + when(() => webSocketChannel.stream).thenAnswer( + (_) => webSocketController.stream, + ); + when(() => webSocketSink.add(any())).thenAnswer((invocation) { + webSocketController.add(invocation.positionalArguments.first); + }); + when(() => webSocketSink.close(any(), any())).thenAnswer( + (_) async { + webSocketController.close(); + // re-initializing for future events + webSocketController = StreamController.broadcast(); + }, + ); + }); + + tearDown(() { + tokenManager.reset(); + webSocket.disconnect(); + }); + + test('`connect` successfully with the provided user', () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final event = await webSocket.connect(user); + + expect(event.type, EventType.healthCheck); + expect(event.connectionId, connectionId); + expect(event.me, isNotNull); + expect(event.me!.id, user.id); + + addTearDown(timer.cancel); + }); + + test('`connect` should throw if already in connection attempt', () async { + final user = OwnUser(id: 'test-user'); + webSocket.connect(user); + try { + // calling again before previous attempt finishes + await webSocket.connect(user); + } catch (e) { + expect(e, isA()); + } + }); + + test('`connect` should throw if `onMessage` contains error', () async { + final user = OwnUser(id: 'test-user'); + final error = ErrorResponse() + ..code = 333 + ..message = 'Invalid request'; + // Sends error event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + webSocketSink.add(json.encode({'error': error})); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.disconnected, + ]), + ); + + try { + await webSocket.connect(user); + } catch (e) { + expect(e, isA()); + final err = e as StreamWebSocketError; + expect(err.code, error.code); + expect(err.message, error.message); + } + + addTearDown(timer.cancel); + }); + + test( + 'should `reconnect` automatically ' + 'if `onMessage` throws error after getting connected', + () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + // starts reconnecting + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + await webSocket.connect(user); + + final error = ErrorResponse() + ..code = 333 + ..message = 'Invalid request'; + // Sends error event to web-socket stream + webSocketSink.add(json.encode({'error': error})); + + final reconnectTimer = Timer(const Duration(seconds: 3), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expect(webSocket.connectionId, connectionId); + + addTearDown(() { + timer.cancel(); + reconnectTimer.cancel(); + }); + }, + ); + + test( + '`onMessage` should handle `health.check` event if `me` is null', + () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + ]), + ); + + final event = await webSocket.connect(user); + + expect(event.type, EventType.healthCheck); + expect(event.connectionId, connectionId); + expect(event.me, isNotNull); + expect(event.me!.id, user.id); + + const newConnectionId = 'new-connection-id'; + final healthCheckEvent = Event( + type: EventType.healthCheck, + connectionId: newConnectionId, + ); + webSocketSink.add(json.encode(healthCheckEvent)); + + await Future.delayed(const Duration(milliseconds: 300)); + + expectLater(webSocket.connectionId, newConnectionId); + + addTearDown(timer.cancel); + }, + ); + + test('should call `onConnectionError` if web-socket stream throws', () async { + final user = OwnUser(id: 'test-user'); + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + const error = StreamWebSocketError('test-error'); + webSocketSink.addError(error); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + // throws error, reconnects + ConnectionStatus.connected, + ]), + ); + + webSocket.connect(user); + + // Assuming web-socket stream will add error + // and web-socket now trying to reconnect + await Future.delayed(const Duration(seconds: 3)); + + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + + addTearDown(timer.cancel); + }); + + test( + 'should call `onConnectionClosed` if web-socket stream throws', + () async { + final user = OwnUser(id: 'test-user'); + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + webSocketSink.close(); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + // throws error, reconnects + ConnectionStatus.connected, + ]), + ); + + webSocket.connect(user); + + // Assuming web-socket stream will add error + // and web-socket now trying to reconnect + await Future.delayed(const Duration(seconds: 3)); + + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + + addTearDown(timer.cancel); + }, + ); + + test('`disconnect` successfully disconnects the current user', () async { + final user = OwnUser(id: 'test-user'); + const connectionId = 'test-connection-id'; + // Sends connect event to web-socket stream + final timer = Timer(const Duration(milliseconds: 300), () { + final event = Event( + type: EventType.healthCheck, + connectionId: connectionId, + me: user, + ); + webSocketSink.add(json.encode(event)); + }); + + expectLater( + webSocket.connectionStatusStream, + emitsInOrder([ + ConnectionStatus.disconnected, + ConnectionStatus.connecting, + ConnectionStatus.connected, + // after disconnect + ConnectionStatus.disconnected, + ]), + ); + + final event = await webSocket.connect(user); + + expect(event.type, EventType.healthCheck); + expect(event.connectionId, connectionId); + expect(event.me?.id, user.id); + + webSocket.disconnect(); + + addTearDown(timer.cancel); + }); +} diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e0b60fad..3407178c 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,61 @@ +## 2.0.0-nullsafety.8 + +đŸ›‘ī¸ Breaking Changes from `2.0.0-nullsafety.7` + +- `ChannelListCore` options property is removed in favor of individual properties + - `options.state` -> bool state + - `options.watch` -> bool watch + - `options.presence` -> bool presence +- `UserListView` options property is removed in favor of individual properties + - `options.presence` -> bool presence +- `MessageBuilder` and `ParentMessageBuilder` signature is now + +```dart +typedef MessageBuilder = Widget Function( + BuildContext, + MessageDetails, + List, + MessageWidget defaultMessageWidget, + ); +``` + +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 +- `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 +- `MessageListView` not rendering if the user is not a member of the channel + +## 2.0.0-nullsafety.7 + +- Minor fixes and improvements +- Updated `stream_chat_core` dependency +- Fixed a bug with connectivity implementation + +## 2.0.0-nullsafety.6 + +- Minor fixes and improvements +- Updated `stream_chat_core` dependency +- 🛑 **BREAKING** Updated StreamChatThemeData.reactionIcons to accept custom builder + +## 2.0.0-nullsafety.5 + +- Minor fixes and improvements +- Updated `stream_chat_core` dependency +- Performance improvements +- Added pinMessage ui support +- Added `MessageListView.threadSeparatorBuilder` property + ## 2.0.0-nullsafety.4 - Minor fixes and improvements @@ -119,7 +177,8 @@ ## 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 @@ -338,27 +397,27 @@ ## 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 information. +- 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 @override - Widget build(BuildContext context) { - return MaterialApp( - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - themeMode: ThemeMode.system, - builder: (context, widget) { - return StreamChat( - child: widget, - client: client, - ); - }, - home: ChannelListPage(), - ); +Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.system, + builder: (context, widget) { + return StreamChat( + child: widget, + client: client, + ); + }, + home: ChannelListPage(), + ); ``` - Fix reaction bubble going below previous message on iOS @@ -442,7 +501,8 @@ - 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/analysis_options.yaml b/packages/stream_chat_flutter/analysis_options.yaml deleted file mode 100644 index f0a87ea5..00000000 --- a/packages/stream_chat_flutter/analysis_options.yaml +++ /dev/null @@ -1,150 +0,0 @@ -analyzer: - enable-experiment: - - extension-methods - exclude: - - lib/**/*.g.dart - - example/** - - lib/src/emoji - - lib/**/*.freezed.dart - - test/** - -linter: - rules: - # these rules are documented on and in the same order as - # the Dart Lint rules page to make maintenance easier - # https://github.com/dart-lang/linter/blob/master/example/all.yaml - - always_use_package_imports - - avoid_empty_else - - avoid_relative_lib_imports - - avoid_slow_async_io - - avoid_types_as_parameter_names - - cancel_subscriptions - - close_sinks - - control_flow_in_finally - - empty_statements - - hash_and_equals - - invariant_booleans - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - literal_only_boolean_expressions - - no_adjacent_strings_in_list - - no_duplicate_case_values - - no_logic_in_create_state - - prefer_void_to_null - - test_types_in_equals - - throw_in_finally - - unnecessary_statements - - unrelated_type_equality_checks - - omit_local_variable_types - - use_key_in_widget_constructors - - valid_regexps - - always_declare_return_types - - always_require_non_null_named_parameters - - annotate_overrides - - avoid_bool_literals_in_conditional_expressions - - avoid_catching_errors - - avoid_init_to_null - - avoid_null_checks_in_equality_operators - - avoid_positional_boolean_parameters - - avoid_private_typedef_functions - - avoid_redundant_argument_values - - avoid_return_types_on_setters - - avoid_returning_null_for_void - - avoid_shadowing_type_parameters - - avoid_single_cascade_in_expression_statements - - avoid_unnecessary_containers - - avoid_unused_constructor_parameters - - await_only_futures - - camel_case_extensions - - camel_case_types - - cascade_invocations - - - constant_identifier_names - - curly_braces_in_flow_control_structures - - directives_ordering - - empty_catches - - empty_constructor_bodies - - exhaustive_cases - - file_names - - implementation_imports - - join_return_with_assignment - - leading_newlines_in_multiline_strings - - library_names - - library_prefixes - - lines_longer_than_80_chars - - missing_whitespace_between_adjacent_strings - - non_constant_identifier_names - - null_closures - - one_member_abstracts - - only_throw_errors - - package_api_docs - - package_prefixed_library_names - - parameter_assignments - - prefer_adjacent_string_concatenation - - prefer_asserts_in_initializer_lists - - prefer_asserts_with_message - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - prefer_constructors_over_static_methods - - prefer_contains - - prefer_equal_for_default_values - - prefer_expression_function_bodies - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - - prefer_int_literals - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - prefer_is_not_empty - - prefer_is_not_operator - - prefer_null_aware_operators - - prefer_single_quotes - - prefer_spread_collections - - prefer_typing_uninitialized_variables - - provide_deprecation_message - - public_member_api_docs - - recursive_getters - - sized_box_for_whitespace - - slash_for_doc_comments - - sort_child_properties_last - - sort_constructors_first - - sort_unnamed_constructors_first - - - type_annotate_public_apis - - type_init_formals - - unnecessary_await_in_return - - unnecessary_brace_in_string_interps - - unnecessary_const - - unnecessary_getters_setters - - unnecessary_lambdas - - unnecessary_new - - unnecessary_null_aware_assignments - - unnecessary_null_in_if_null_operators - - unnecessary_nullable_for_final_variable_declarations - - unnecessary_parenthesis - - unnecessary_raw_strings - - unnecessary_string_escapes - - unnecessary_string_interpolations - - unnecessary_this - - use_is_even_rather_than_modulo - - use_late_for_private_fields_and_variables - - use_rethrow_when_possible - - use_setters_to_change_properties - - use_to_and_as_if_applicable - - package_names - - sort_pub_dependencies - - - cast_nullable_to_non_nullable - - unnecessary_null_checks - - tighten_type_of_initializing_formals - - null_check_on_nullable_type_parameter diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart index fc55f6d9..6789a69d 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart @@ -101,12 +101,12 @@ class ChannelListPage extends StatelessWidget { StreamChatTheme.of(context).channelPreviewTheme.title!.copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(opacity), ), ), subtitle: Text(subtitle), - trailing: channel.state!.unreadCount! > 0 + trailing: channel.state!.unreadCount > 0 ? CircleAvatar( radius: 10, child: Text(channel.state!.unreadCount.toString()), diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart index 7a75c037..8f1118dc 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart @@ -93,6 +93,7 @@ class ChannelPage extends StatelessWidget { BuildContext context, MessageDetails details, List messages, + MessageWidget _, ) { final message = details.message; final isCurrentUser = StreamChat.of(context).user!.id == message.user!.id; diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart index c49ffaf1..e4f5b8a5 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart @@ -50,9 +50,9 @@ class MyApp extends StatelessWidget { ), ), otherMessageTheme: MessageTheme( - messageBackgroundColor: colorTheme.black, + messageBackgroundColor: colorTheme.textHighEmphasis, messageText: TextStyle( - color: colorTheme.white, + color: colorTheme.barsBg, ), avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(8), diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index 3feec144..408bf9e1 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -23,6 +23,10 @@ environment: dependencies: flutter: sdk: flutter +# stream_chat: +# path: ../../stream_chat +# stream_chat_flutter_core: +# path: ../../stream_chat_flutter_core stream_chat_flutter: path: ../ stream_chat_persistence: diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart index ba369e5d..b068a8f9 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart @@ -35,7 +35,7 @@ class AttachmentTitle extends StatelessWidget { attachment.title!, overflow: TextOverflow.ellipsis, style: messageTheme.messageText?.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: StreamChatTheme.of(context).colorTheme.accentPrimary, fontWeight: FontWeight.bold, ), ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart index 825def58..b1b29c4c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart @@ -129,7 +129,7 @@ class _PreparingState extends StatelessWidget { alignment: Alignment.topRight, child: _IconButton( icon: StreamSvgIcon.close( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, ), onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), @@ -169,7 +169,7 @@ class _InProgressState extends StatelessWidget { alignment: Alignment.topRight, child: _IconButton( icon: StreamSvgIcon.close( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, ), onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), @@ -208,7 +208,7 @@ class _FailedState extends StatelessWidget { children: [ _IconButton( icon: StreamSvgIcon.retry( - color: theme.colorTheme.white, + color: theme.colorTheme.barsBg, ), onPressed: () { channel.retryAttachmentUpload(messageId, attachmentId); @@ -228,7 +228,7 @@ class _FailedState extends StatelessWidget { child: Text( 'UPLOAD ERROR', style: theme.textTheme.footnote.copyWith( - color: theme.colorTheme.white, + color: theme.colorTheme.barsBg, ), ), ), @@ -247,7 +247,7 @@ class _SuccessState extends StatelessWidget { backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark, maxRadius: 12, child: StreamSvgIcon.check( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart index ee79d959..e92087b5 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart @@ -75,12 +75,14 @@ class AttachmentError extends StatelessWidget { child: Container( width: size?.width, height: size?.height, - color: - StreamChatTheme.of(context).colorTheme.accentRed.withOpacity(.1), + color: StreamChatTheme.of(context) + .colorTheme + .accentError + .withOpacity(.1), child: Center( child: Icon( Icons.error_outline, - color: StreamChatTheme.of(context).colorTheme.black, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index a676e576..99c3ec40 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/upload_progress_indicator.dart'; import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'package:stream_chat_flutter/src/upload_progress_indicator.dart'; // ignore: always_use_package_imports import 'attachment_widget.dart'; @@ -54,10 +54,10 @@ class FileAttachment extends AttachmentWidget { width: size?.width ?? 100, height: 56, decoration: BoxDecoration( - color: colorTheme.white, + color: colorTheme.barsBg, borderRadius: BorderRadius.circular(12), border: Border.all( - color: colorTheme.greyWhisper, + color: colorTheme.borders, ), ), child: Row( @@ -103,7 +103,7 @@ class FileAttachment extends AttachmentWidget { Widget _getFileTypeImage(BuildContext context) { if (isImageAttachment) { return Material( - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, type: MaterialType.transparency, shape: _getDefaultShape(context), child: source.when( @@ -141,8 +141,8 @@ class FileAttachment extends AttachmentWidget { final colorTheme = StreamChatTheme.of(context).colorTheme; return Shimmer.fromColors( - baseColor: colorTheme.greyGainsboro, - highlightColor: colorTheme.whiteSmoke, + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, child: image, ); }, @@ -154,7 +154,7 @@ class FileAttachment extends AttachmentWidget { if (isVideoAttachment) { return Material( - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, type: MaterialType.transparency, shape: _getDefaultShape(context), child: source.when( @@ -217,7 +217,7 @@ class FileAttachment extends AttachmentWidget { preparing: () => Padding( padding: const EdgeInsets.all(8), child: _buildButton( - icon: StreamSvgIcon.close(color: theme.colorTheme.white), + icon: StreamSvgIcon.close(color: theme.colorTheme.barsBg), fillColor: theme.colorTheme.overlayDark, onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), @@ -225,7 +225,7 @@ class FileAttachment extends AttachmentWidget { inProgress: (_, __) => Padding( padding: const EdgeInsets.all(8), child: _buildButton( - icon: StreamSvgIcon.close(color: theme.colorTheme.white), + icon: StreamSvgIcon.close(color: theme.colorTheme.barsBg), fillColor: theme.colorTheme.overlayDark, onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), @@ -233,15 +233,15 @@ class FileAttachment extends AttachmentWidget { success: () => Padding( padding: const EdgeInsets.all(8), child: CircleAvatar( - backgroundColor: theme.colorTheme.accentBlue, + backgroundColor: theme.colorTheme.accentPrimary, maxRadius: 12, - child: StreamSvgIcon.check(color: theme.colorTheme.white), + child: StreamSvgIcon.check(color: theme.colorTheme.barsBg), ), ), failed: (_) => Padding( padding: const EdgeInsets.all(8), child: _buildButton( - icon: StreamSvgIcon.retry(color: theme.colorTheme.white), + icon: StreamSvgIcon.retry(color: theme.colorTheme.barsBg), fillColor: theme.colorTheme.overlayDark, onPressed: () => channel.retryAttachmentUpload( message.id, @@ -253,7 +253,8 @@ class FileAttachment extends AttachmentWidget { if (message.status == MessageSendingStatus.sent) { trailingWidget = IconButton( - icon: StreamSvgIcon.cloudDownload(color: theme.colorTheme.black), + icon: StreamSvgIcon.cloudDownload( + color: theme.colorTheme.textHighEmphasis), visualDensity: VisualDensity.compact, splashRadius: 16, onPressed: () { @@ -272,7 +273,7 @@ class FileAttachment extends AttachmentWidget { final theme = StreamChatTheme.of(context); final size = attachment.file?.size ?? attachment.extraData['file_size']; final textStyle = theme.textTheme.footnote.copyWith( - color: theme.colorTheme.grey, + color: theme.colorTheme.textLowEmphasis, ); return attachment.uploadState.when( preparing: () => Text(fileSize(size), style: textStyle), @@ -282,7 +283,7 @@ class FileAttachment extends AttachmentWidget { showBackground: false, padding: EdgeInsets.zero, textStyle: textStyle, - progressIndicatorColor: theme.colorTheme.accentBlue, + progressIndicatorColor: theme.colorTheme.accentPrimary, ), success: () => Text(fileSize(size), style: textStyle), failed: (_) => Text('UPLOAD ERROR', style: textStyle), diff --git a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index 1bbbd54f..f14abffc 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -51,9 +51,9 @@ class GiphyAttachment extends AttachmentWidget { mainAxisSize: MainAxisSize.min, children: [ Card( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, elevation: 2, - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( topRight: Radius.circular(16), @@ -83,7 +83,7 @@ class GiphyAttachment extends AttachmentWidget { style: TextStyle( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), ), overflow: TextOverflow.ellipsis, @@ -117,7 +117,7 @@ class GiphyAttachment extends AttachmentWidget { Container( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.2), width: double.infinity, height: 0.5, @@ -141,7 +141,7 @@ class GiphyAttachment extends AttachmentWidget { .copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), ), ), @@ -152,7 +152,7 @@ class GiphyAttachment extends AttachmentWidget { width: 0.5, color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.2), height: 50, ), @@ -173,7 +173,7 @@ class GiphyAttachment extends AttachmentWidget { .copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), ), maxLines: 1, @@ -185,7 +185,7 @@ class GiphyAttachment extends AttachmentWidget { width: 0.5, color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.2), height: 50, ), @@ -203,7 +203,7 @@ class GiphyAttachment extends AttachmentWidget { style: TextStyle( color: StreamChatTheme.of(context) .colorTheme - .accentBlue, + .accentPrimary, fontWeight: FontWeight.bold, ), ), @@ -226,7 +226,7 @@ class GiphyAttachment extends AttachmentWidget { StreamSvgIcon.eye( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5), size: 16, ), @@ -241,7 +241,7 @@ class GiphyAttachment extends AttachmentWidget { .copyWith( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(0.5)), ), ], @@ -306,8 +306,8 @@ class GiphyAttachment extends AttachmentWidget { final colorTheme = StreamChatTheme.of(context).colorTheme; return Shimmer.fromColors( - baseColor: colorTheme.greyGainsboro, - highlightColor: colorTheme.whiteSmoke, + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, child: image, ); }, @@ -322,7 +322,7 @@ class GiphyAttachment extends AttachmentWidget { child: Material( color: StreamChatTheme.of(context) .colorTheme - .black + .textHighEmphasis .withOpacity(.5), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -335,13 +335,14 @@ class GiphyAttachment extends AttachmentWidget { child: Row( children: [ StreamSvgIcon.lightning( - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, size: 16, ), Text( 'GIPHY', style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.white, + color: + StreamChatTheme.of(context).colorTheme.barsBg, fontWeight: FontWeight.bold, fontSize: 11, ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 5c431d6e..20174567 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -74,16 +74,16 @@ class ImageAttachment extends AttachmentWidget { if (imageUri.host == 'stream-io-cdn.com') { imageUri = imageUri.replace(queryParameters: { ...imageUri.queryParameters, - 'h': '500', - 'w': '500', + 'h': '400', + 'w': '400', 'crop': 'center', 'resize': 'crop', }); } else if (imageUri.host == 'stream-cloud-uploads.imgix.net') { imageUri = imageUri.replace(queryParameters: { ...imageUri.queryParameters, - 'height': '500', - 'width': '500', + 'height': '400', + 'width': '400', 'fit': 'crop', }); } @@ -92,10 +92,10 @@ class ImageAttachment extends AttachmentWidget { return _buildImageAttachment( context, CachedNetworkImage( - cacheKey: imageUri.path, + cacheKey: imageUrl, height: size?.height, width: size?.width, - placeholder: (_, __) { + placeholder: (context, __) { final image = Image.asset( 'images/placeholder.png', fit: BoxFit.cover, @@ -103,8 +103,8 @@ class ImageAttachment extends AttachmentWidget { ); final colorTheme = StreamChatTheme.of(context).colorTheme; return Shimmer.fromColors( - baseColor: colorTheme.greyGainsboro, - highlightColor: colorTheme.whiteSmoke, + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, child: image, ); }, diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart index 363fac45..ea189b91 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart @@ -72,7 +72,7 @@ class AttachmentActionsModal extends StatelessWidget { 'Reply', StreamSvgIcon.iconCurveLineLeftUp( size: 24, - color: theme.colorTheme.grey, + color: theme.colorTheme.textLowEmphasis, ), () { Navigator.pop(context, ReturnActionType.reply); @@ -83,7 +83,7 @@ class AttachmentActionsModal extends StatelessWidget { 'Show in Chat', StreamSvgIcon.eye( size: 24, - color: theme.colorTheme.black, + color: theme.colorTheme.textHighEmphasis, ), onShowMessage, ), @@ -93,7 +93,7 @@ class AttachmentActionsModal extends StatelessWidget { 'Save ${message.attachments[currentIndex].type == 'video' ? 'Video' : 'Image'}', StreamSvgIcon.iconSave( size: 24, - color: theme.colorTheme.grey, + color: theme.colorTheme.textLowEmphasis, ), () { final attachment = message.attachments[currentIndex]; @@ -144,7 +144,7 @@ class AttachmentActionsModal extends StatelessWidget { 'Delete', StreamSvgIcon.delete( size: 24, - color: theme.colorTheme.accentRed, + color: theme.colorTheme.accentError, ), () { final channel = StreamChannel.of(context).channel; @@ -165,7 +165,7 @@ class AttachmentActionsModal extends StatelessWidget { ..maybePop(); } }, - color: theme.colorTheme.accentRed, + color: theme.colorTheme.accentError, ), ] .map((e) => Align( @@ -175,7 +175,7 @@ class AttachmentActionsModal extends StatelessWidget { .insertBetween( Container( height: 1, - color: theme.colorTheme.greyWhisper, + color: theme.colorTheme.borders, ), ), ), @@ -196,7 +196,7 @@ class AttachmentActionsModal extends StatelessWidget { }) => Material( key: key, - color: StreamChatTheme.of(context).colorTheme.white, + color: StreamChatTheme.of(context).colorTheme.barsBg, child: InkWell( onTap: onTap, child: Padding( @@ -241,7 +241,7 @@ class AttachmentActionsModal extends StatelessWidget { width: 182, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: theme.colorTheme.white, + color: theme.colorTheme.barsBg, ), child: Center( child: progress == null @@ -249,7 +249,7 @@ class AttachmentActionsModal extends StatelessWidget { height: 100, width: 100, child: StreamSvgIcon.error( - color: theme.colorTheme.greyGainsboro, + color: theme.colorTheme.disabled, ), ) : progress.toProgressIndicatorValue == 1.0 @@ -258,7 +258,7 @@ class AttachmentActionsModal extends StatelessWidget { height: 160, width: 160, child: StreamSvgIcon.check( - color: theme.colorTheme.greyGainsboro, + color: theme.colorTheme.disabled, ), ) : SizedBox( @@ -271,14 +271,14 @@ class AttachmentActionsModal extends StatelessWidget { value: progress.toProgressIndicatorValue, strokeWidth: 8, valueColor: AlwaysStoppedAnimation( - theme.colorTheme.accentBlue, + theme.colorTheme.accentPrimary, ), ), Center( child: Text( '${progress.toPercentage}%', style: theme.textTheme.headline.copyWith( - color: theme.colorTheme.grey, + color: theme.colorTheme.textLowEmphasis, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/back_button.dart b/packages/stream_chat_flutter/lib/src/back_button.dart index e8fe7e31..6bd3e0cc 100644 --- a/packages/stream_chat_flutter/lib/src/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/back_button.dart @@ -44,7 +44,7 @@ class StreamBackButton extends StatelessWidget { padding: const EdgeInsets.all(14), child: StreamSvgIcon.left( size: 24, - color: StreamChatTheme.of(context).colorTheme.black, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), ), if (showUnreads) diff --git a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart index eaabb7e2..368bfbd8 100644 --- a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart @@ -32,7 +32,7 @@ class _ChannelBottomSheetState extends State { final isOwner = userAsMember.role == 'owner'; return Material( - color: _streamChatThemeData.colorTheme.white, + color: _streamChatThemeData.colorTheme.barsBg, clipBehavior: Clip.antiAlias, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -146,7 +146,7 @@ class _ChannelBottomSheetState extends State { leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.user( - color: _streamChatThemeData.colorTheme.grey, + color: _streamChatThemeData.colorTheme.textLowEmphasis, ), ), title: 'View Info', @@ -157,7 +157,7 @@ class _ChannelBottomSheetState extends State { leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.userRemove( - color: _streamChatThemeData.colorTheme.grey, + color: _streamChatThemeData.colorTheme.textLowEmphasis, ), ), title: 'Leave Group', @@ -176,11 +176,11 @@ class _ChannelBottomSheetState extends State { leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.delete( - color: _streamChatThemeData.colorTheme.accentRed, + color: _streamChatThemeData.colorTheme.accentError, ), ), title: 'Delete Conversation', - titleColor: _streamChatThemeData.colorTheme.accentRed, + titleColor: _streamChatThemeData.colorTheme.accentError, onTap: () async { setState(() { _showActions = false; @@ -195,7 +195,7 @@ class _ChannelBottomSheetState extends State { leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamSvgIcon.closeSmall( - color: _streamChatThemeData.colorTheme.grey, + color: _streamChatThemeData.colorTheme.textLowEmphasis, ), ), title: 'Cancel', @@ -224,7 +224,7 @@ class _ChannelBottomSheetState extends State { question: 'Are you sure you want to delete this conversation?', cancelText: 'CANCEL', icon: StreamSvgIcon.delete( - color: _streamChatThemeData.colorTheme.accentRed, + color: _streamChatThemeData.colorTheme.accentError, ), ); final channel = _streamChannelState.channel; @@ -242,7 +242,7 @@ class _ChannelBottomSheetState extends State { question: 'Are you sure you want to leave this conversation?', cancelText: 'CANCEL', icon: StreamSvgIcon.userRemove( - color: _streamChatThemeData.colorTheme.accentRed, + color: _streamChatThemeData.colorTheme.accentError, ), ); if (res == true) { diff --git a/packages/stream_chat_flutter/lib/src/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel_header.dart index 7773b36f..16c1f1b7 100644 --- a/packages/stream_chat_flutter/lib/src/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_header.dart @@ -133,8 +133,7 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { } return InfoTile( - // ignore: avoid_bool_literals_in_conditional_expressions - showMessage: showConnectionStateTile ? showStatus : false, + showMessage: showConnectionStateTile && showStatus, message: statusString, child: AppBar( textTheme: Theme.of(context).textTheme, diff --git a/packages/stream_chat_flutter/lib/src/channel_image.dart b/packages/stream_chat_flutter/lib/src/channel_image.dart index 19495809..9ac07033 100644 --- a/packages/stream_chat_flutter/lib/src/channel_image.dart +++ b/packages/stream_chat_flutter/lib/src/channel_image.dart @@ -83,33 +83,35 @@ class ChannelImage extends StatelessWidget { Widget build(BuildContext context) { final streamChat = StreamChat.of(context); final channel = this.channel ?? StreamChannel.of(context).channel; - return StreamBuilder>( + return BetterStreamBuilder>( stream: channel.extraDataStream, initialData: channel.extraData, - builder: (context, snapshot) { + builder: (context, data) { String? image; final chatThemeData = StreamChatTheme.of(context); - if (snapshot.data!.containsKey('image') == true) { - image = snapshot.data!['image']; + if (data.containsKey('image') == true) { + image = data['image']; } else if (channel.state?.members.length == 2) { final otherMember = channel.state?.members .firstWhere((member) => member.user?.id != streamChat.user?.id); - return StreamBuilder( - stream: streamChat.client.state.usersStream.map( - (users) => users[otherMember?.userId] ?? otherMember!.user!), + return BetterStreamBuilder( + stream: streamChat.client.state.usersStream + .map((users) => + users[otherMember?.userId] ?? otherMember!.user!) + .distinct(), initialData: otherMember!.user, - builder: (context, snapshot) => UserAvatar( + builder: (context, user) => UserAvatar( borderRadius: borderRadius ?? chatThemeData .channelPreviewTheme.avatarTheme?.borderRadius, - user: snapshot.data ?? otherMember.user!, + user: user ?? otherMember.user!, constraints: constraints ?? chatThemeData .channelPreviewTheme.avatarTheme?.constraints, onTap: onTap != null ? (_) => onTap!() : null, selected: selected, - selectionColor: - selectionColor ?? chatThemeData.colorTheme.accentBlue, + selectionColor: selectionColor ?? + chatThemeData.colorTheme.accentPrimary, selectionThickness: selectionThickness, )); } else { @@ -130,7 +132,7 @@ class ChannelImage extends StatelessWidget { onTap: onTap, selected: selected, selectionColor: - selectionColor ?? chatThemeData.colorTheme.accentBlue, + selectionColor ?? chatThemeData.colorTheme.accentPrimary, selectionThickness: selectionThickness, ); } @@ -142,7 +144,7 @@ class ChannelImage extends StatelessWidget { constraints: constraints ?? chatThemeData.channelPreviewTheme.avatarTheme?.constraints, decoration: BoxDecoration( - color: chatThemeData.colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ), child: Stack( alignment: Alignment.center, @@ -153,11 +155,9 @@ class ChannelImage extends StatelessWidget { imageUrl: image, errorWidget: (_, __, ___) => Center( child: Text( - snapshot.data?.containsKey('name') ?? false - ? snapshot.data!['name'][0] - : '', + data.containsKey('name') ? data['name'][0] : '', style: TextStyle( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, fontWeight: FontWeight.bold, ), ), @@ -189,7 +189,7 @@ class ChannelImage extends StatelessWidget { child: Container( constraints: constraints ?? chatThemeData.ownMessageTheme.avatarTheme?.constraints, - color: selectionColor ?? chatThemeData.colorTheme.accentBlue, + color: selectionColor ?? chatThemeData.colorTheme.accentPrimary, child: Padding( padding: EdgeInsets.all(selectionThickness), child: child, diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart index f59ac2e0..3be26ec8 100644 --- a/packages/stream_chat_flutter/lib/src/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -11,6 +11,7 @@ class ChannelInfo extends StatelessWidget { required this.channel, this.textStyle, this.showTypingIndicator = true, + this.parentId, }) : super(key: key); /// The channel about which the info is to be displayed @@ -22,17 +23,20 @@ class ChannelInfo extends StatelessWidget { /// If true the typing indicator will be rendered if a user is typing final bool showTypingIndicator; + /// Id of the parent message in case of a thread + final String? parentId; + @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; - return StreamBuilder>( - stream: channel.state?.membersStream, - initialData: channel.state?.members, - builder: (context, snapshot) => ConnectionStatusBuilder( + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, data) => ConnectionStatusBuilder( statusBuilder: (context, status) { switch (status) { case ConnectionStatus.connected: - return _buildConnectedTitleState(context, snapshot.data); + return _buildConnectedTitleState(context, data); case ConnectionStatus.connecting: return _buildConnectingTitleState(context); case ConnectionStatus.disconnected: @@ -88,6 +92,7 @@ class ChannelInfo extends StatelessWidget { } return TypingIndicator( + parentId: parentId, alignment: Alignment.center, alternativeWidget: alternativeWidget, style: textStyle, @@ -132,14 +137,13 @@ class ChannelInfo extends StatelessWidget { vertical: VisualDensity.minimumDensity, ), ), - onPressed: () async { - await client.disconnect(); - await client.connect(); - }, + onPressed: () => client + ..closeConnection() + ..openConnection(), child: Text( 'Try Again', style: textStyle?.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: StreamChatTheme.of(context).colorTheme.accentPrimary, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel_list_header.dart index 4a8f78c3..0db31f80 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_header.dart @@ -154,7 +154,7 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { Color? color; switch (status) { case ConnectionStatus.connected: - color = chatThemeData.colorTheme.accentBlue; + color = chatThemeData.colorTheme.accentPrimary; break; case ConnectionStatus.connecting: color = Colors.grey; @@ -209,7 +209,7 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { return Text( 'Stream Chat', style: chatThemeData.textTheme.headlineBold.copyWith( - color: chatThemeData.colorTheme.black, + color: chatThemeData.colorTheme.textHighEmphasis, ), ); } @@ -254,16 +254,15 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { ), ), TextButton( - onPressed: () async { - await client.disconnect(); - await client.connect(); - }, + onPressed: () => client + ..closeConnection() + ..openConnection(), child: Text( 'Try Again', style: chatThemeData.channelListHeaderTheme.title?.copyWith( fontSize: 16, fontWeight: FontWeight.bold, - color: chatThemeData.colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ), ), ), 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 911e0a41..316d7513 100644 --- a/packages/stream_chat_flutter/lib/src/channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -1,11 +1,10 @@ -import 'package:collection/collection.dart' show IterableExtension; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/channel_bottom_sheet.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -61,9 +60,15 @@ class ChannelListView extends StatefulWidget { const ChannelListView({ Key? key, this.filter, - this.options, this.sort, - this.pagination, + this.state = true, + this.watch = true, + this.presence = false, + this.memberLimit, + this.messageLimit, + this.pagination = const PaginationParams( + limit: 25, + ), this.onChannelTap, this.onChannelLongPress, this.channelWidget, @@ -84,6 +89,7 @@ class ChannelListView extends StatefulWidget { this.onMoreDetailsPressed, this.onDeletePressed, this.swipeActions, + this.channelListController, }) : super(key: key); /// If true a default swipe to action behaviour will be added to this widget @@ -94,12 +100,6 @@ class ChannelListView extends StatefulWidget { /// You can also filter other built-in channel fields. final Filter? filter; - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map? options; - /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options /// can be provided. @@ -108,11 +108,26 @@ class ChannelListView extends StatefulWidget { /// Direction can be ascending or descending. final List>? sort; + /// If true returns the Channel state + final bool state; + + /// If true listen to changes to this Channel in real time. + final bool watch; + + /// If true you’ll receive user presence updates via the websocket events + final bool presence; + + /// Number of members to fetch in each channel + final int? memberLimit; + + /// Number of messages to fetch in each channel + final int? messageLimit; + /// Pagination parameters /// limit: the number of channels to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams? pagination; + final PaginationParams pagination; /// Function called when tapping on a channel /// By default it calls [Navigator.push] building a [MaterialPageRoute] @@ -173,6 +188,12 @@ class ChannelListView extends StatefulWidget { /// List of actions for slidable final List? swipeActions; + /// A [ChannelListController] allows reloading and pagination. + /// Use [ChannelListController.loadData] and + /// [ChannelListController.paginateData] respectively for reloading and + /// pagination. + final ChannelListController? channelListController; + @override _ChannelListViewState createState() => _ChannelListViewState(); } @@ -180,18 +201,21 @@ class ChannelListView extends StatefulWidget { class _ChannelListViewState extends State { final _slideController = SlidableController(); - final _channelListController = ChannelListController(); + late final _defaultController = ChannelListController(); + ChannelListController get _channelListController => + widget.channelListController ?? _defaultController; @override Widget build(BuildContext context) { Widget child = ChannelListCore( - pagination: widget.pagination ?? - const PaginationParams( - limit: 25, - ), - options: widget.options, - sort: widget.sort, filter: widget.filter, + sort: widget.sort, + state: widget.state, + watch: widget.watch, + presence: widget.presence, + memberLimit: widget.memberLimit, + messageLimit: widget.messageLimit, + pagination: widget.pagination, channelListController: _channelListController, listBuilder: widget.listBuilder ?? _buildListView, emptyBuilder: widget.emptyBuilder ?? _buildEmptyWidget, @@ -245,10 +269,7 @@ class _ChannelListViewState extends State { } } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - child: child, - ); + return child; } Widget _buildEmptyWidget(BuildContext context) => LayoutBuilder( @@ -269,7 +290,7 @@ class _ChannelListViewState extends State { padding: const EdgeInsets.all(8), child: StreamSvgIcon.message( size: 136, - color: chatThemeData.colorTheme.greyGainsboro, + color: chatThemeData.colorTheme.disabled, ), ), Padding( @@ -288,7 +309,7 @@ class _ChannelListViewState extends State { 'How about sending your first message to a friend?', textAlign: TextAlign.center, style: chatThemeData.textTheme.body.copyWith( - color: chatThemeData.colorTheme.grey, + color: chatThemeData.colorTheme.textLowEmphasis, ), ), ), @@ -306,7 +327,7 @@ class _ChannelListViewState extends State { child: Text( 'Start a chat', style: chatThemeData.textTheme.bodyBold.copyWith( - color: chatThemeData.colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ), ), ), @@ -341,8 +362,8 @@ class _ChannelListViewState extends State { final chatThemeData = StreamChatTheme.of(context); if (widget.crossAxisCount > 1) { return Shimmer.fromColors( - baseColor: chatThemeData.colorTheme.greyGainsboro, - highlightColor: chatThemeData.colorTheme.whiteSmoke, + baseColor: chatThemeData.colorTheme.disabled, + highlightColor: chatThemeData.colorTheme.inputBg, child: Column( children: [ const SizedBox(height: 4), @@ -370,12 +391,12 @@ class _ChannelListViewState extends State { ); } else { return Shimmer.fromColors( - baseColor: chatThemeData.colorTheme.greyGainsboro, - highlightColor: chatThemeData.colorTheme.whiteSmoke, + baseColor: chatThemeData.colorTheme.disabled, + highlightColor: chatThemeData.colorTheme.inputBg, child: ListTile( leading: Container( decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, shape: BoxShape.circle, ), constraints: const BoxConstraints.tightFor( @@ -391,7 +412,7 @@ class _ChannelListViewState extends State { alignment: Alignment.centerLeft, child: Container( decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, borderRadius: BorderRadius.circular(11), ), constraints: const BoxConstraints.tightFor( @@ -408,7 +429,7 @@ class _ChannelListViewState extends State { alignment: Alignment.centerLeft, child: Container( decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, borderRadius: BorderRadius.circular(11), ), constraints: const BoxConstraints.expand( @@ -420,7 +441,7 @@ class _ChannelListViewState extends State { Container( margin: const EdgeInsets.only(left: 16), decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, borderRadius: BorderRadius.circular(11), ), constraints: const BoxConstraints.tightFor( @@ -465,104 +486,105 @@ class _ChannelListViewState extends State { Widget _listItemBuilder(BuildContext context, int i, List channels) { final channelsBloc = ChannelsBloc.of(context); + final onTap = _getChannelTap(context); + final chatThemeData = StreamChatTheme.of(context); + final backgroundColor = chatThemeData.colorTheme.inputBg; + if (i < channels.length) { final channel = channels[i]; - final onTap = _getChannelTap(context); - final chatThemeData = StreamChatTheme.of(context); - final backgroundColor = chatThemeData.colorTheme.whiteSmoke; return StreamChannel( - key: ValueKey('CHANNEL-${channel.id}'), + key: ValueKey('CHANNEL-${channel.cid}'), channel: channel, - child: Builder( - builder: (context) => Slidable( - controller: _slideController, - enabled: widget.swipeToAction, - actionPane: const SlidableBehindActionPane(), - actionExtentRatio: 0.12, - secondaryActions: widget.swipeActions - ?.map((e) => IconSlideAction( - color: e.color, - iconWidget: e.iconWidget, - onTap: () { - e.onTap?.call(channel); - }, - )) - .toList() ?? - [ + child: Slidable( + controller: _slideController, + enabled: widget.swipeToAction, + actionPane: const SlidableBehindActionPane(), + actionExtentRatio: 0.12, + secondaryActions: widget.swipeActions + ?.map((e) => IconSlideAction( + color: e.color, + iconWidget: e.iconWidget, + onTap: () { + e.onTap?.call(channel); + }, + )) + .toList() ?? + [ + IconSlideAction( + color: backgroundColor, + icon: Icons.more_horiz, + onTap: widget.onMoreDetailsPressed != null + ? () { + widget.onMoreDetailsPressed!(channel); + } + : () { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + builder: (context) => StreamChannel( + channel: channel, + child: ChannelBottomSheet( + onViewInfoTap: () { + widget.onViewInfoTap?.call(channel); + }, + ), + ), + ); + }, + ), + if ([ + 'admin', + 'owner', + ].contains(channel.state!.members + .firstWhereOrNull( + (m) => m.userId == channel.client.state.user?.id) + ?.role)) IconSlideAction( color: backgroundColor, - icon: Icons.more_horiz, - onTap: widget.onMoreDetailsPressed != null + iconWidget: StreamSvgIcon.delete( + color: chatThemeData.colorTheme.accentError, + ), + onTap: widget.onDeletePressed != null ? () { - widget.onMoreDetailsPressed!(channel); + widget.onDeletePressed!(channel); } - : () { - showModalBottomSheet( - clipBehavior: Clip.hardEdge, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), - ), - context: context, - builder: (context) => StreamChannel( - channel: channel, - child: ChannelBottomSheet( - onViewInfoTap: () { - widget.onViewInfoTap?.call(channel); - }, - ), + : () async { + final res = await showConfirmationDialog( + context, + title: 'Delete Conversation', + okText: 'DELETE', + question: + // ignore: lines_longer_than_80_chars + 'Are you sure you want to delete this conversation?', + cancelText: 'CANCEL', + icon: StreamSvgIcon.delete( + color: chatThemeData.colorTheme.accentError, ), ); + if (res == true) { + await channel.delete(); + } }, ), - if ([ - 'admin', - 'owner', - ].contains(channel.state!.members - .firstWhereOrNull( - (m) => m.userId == channel.client.state.user?.id) - ?.role)) - IconSlideAction( - color: backgroundColor, - iconWidget: StreamSvgIcon.delete( - color: chatThemeData.colorTheme.accentRed, - ), - onTap: widget.onDeletePressed != null - ? () { - widget.onDeletePressed!(channel); - } - : () async { - final res = await showConfirmationDialog( - context, - title: 'Delete Conversation', - okText: 'DELETE', - question: - // ignore: lines_longer_than_80_chars - 'Are you sure you want to delete this conversation?', - cancelText: 'CANCEL', - icon: StreamSvgIcon.delete( - color: chatThemeData.colorTheme.accentRed, - ), - ); - if (res == true) { - await channel.delete(); - } - }, - ), - ], - child: Container( - color: chatThemeData.colorTheme.whiteSnow, - child: widget.channelPreviewBuilder?.call(context, channel) ?? - ChannelPreview( - onLongPress: widget.onChannelLongPress, - channel: channel, - onImageTap: () => widget.onImageTap?.call(channel), - onTap: (channel) => onTap(channel, widget.channelWidget), - ), + ], + child: DecoratedBox( + decoration: BoxDecoration( + color: chatThemeData.colorTheme.appBg, ), + child: widget.channelPreviewBuilder?.call(context, channel) ?? + ChannelPreview( + onLongPress: widget.onChannelLongPress, + channel: channel, + onImageTap: () => widget.onImageTap?.call(channel), + onTap: (channel) => onTap(channel, widget.channelWidget), + ), ), ), ); @@ -636,15 +658,13 @@ class _ChannelListViewState extends State { context, ChannelsBlocState channelsProvider, ) => - StreamBuilder( + BetterStreamBuilder( stream: channelsProvider.queryChannelsLoading, initialData: false, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Container( + errorBuilder: (context, err) => Container( color: StreamChatTheme.of(context) .colorTheme - .accentRed + .accentError .withOpacity(.2), child: const Padding( padding: EdgeInsets.symmetric(vertical: 16), @@ -652,17 +672,15 @@ class _ChannelListViewState extends State { child: Text('Error loading channels'), ), ), - ); - } - return snapshot.data! - ? const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: CircularProgressIndicator(), - ), - ) - : const Offstage(); - }); + ), + builder: (context, data) => data + ? const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ) + : const Offstage()); Widget _separatorBuilder(context, i) { final effect = StreamChatTheme.of(context).colorTheme.borderBottom; diff --git a/packages/stream_chat_flutter/lib/src/channel_name.dart b/packages/stream_chat_flutter/lib/src/channel_name.dart index 5db3341a..4819ecc7 100644 --- a/packages/stream_chat_flutter/lib/src/channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel_name.dart @@ -26,11 +26,11 @@ class ChannelName extends StatelessWidget { final client = StreamChat.of(context); final channel = StreamChannel.of(context).channel; - return StreamBuilder>( + return BetterStreamBuilder>( stream: channel.extraDataStream, initialData: channel.extraData, - builder: (context, snapshot) => _buildName( - snapshot.data!, + builder: (context, data) => _buildName( + data, channel.state?.members, client, ), diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart index 3189b0cb..231b5f94 100644 --- a/packages/stream_chat_flutter/lib/src/channel_preview.dart +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -1,4 +1,5 @@ -import 'package:collection/collection.dart' show IterableExtension; +import 'package:collection/collection.dart' + show IterableExtension, ListEquality; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:jiffy/jiffy.dart'; @@ -69,12 +70,12 @@ class ChannelPreview extends StatelessWidget { Widget build(BuildContext context) { final channelPreviewTheme = StreamChatTheme.of(context).channelPreviewTheme; final streamChatState = StreamChat.of(context); - - return StreamBuilder( + return BetterStreamBuilder( stream: channel.isMutedStream, initialData: channel.isMuted, - builder: (context, snapshot) => Opacity( - opacity: snapshot.data! ? 0.5 : 1, + builder: (context, data) => AnimatedOpacity( + opacity: data ? 0.5 : 1, + duration: const Duration(milliseconds: 300), child: ListTile( visualDensity: VisualDensity.compact, contentPadding: const EdgeInsets.symmetric( @@ -103,14 +104,16 @@ class ChannelPreview extends StatelessWidget { textStyle: channelPreviewTheme.title, ), ), - StreamBuilder>( + BetterStreamBuilder?>( stream: channel.state?.membersStream, initialData: channel.state?.members, - builder: (context, snapshot) { - if (!snapshot.hasData || - snapshot.data!.isEmpty || - !snapshot.data!.any((Member e) => - e.user!.id == channel.client.state.user?.id)) { + comparator: const ListEquality().equals, + builder: (context, members) { + if (members?.isEmpty == true || + members?.any((Member e) => + e.user!.id == + channel.client.state.user?.id) != + true) { return const SizedBox(); } return UnreadIndicator( @@ -159,14 +162,14 @@ class ChannelPreview extends StatelessWidget { )); } - Widget _buildDate(BuildContext context) => StreamBuilder( + Widget _buildDate(BuildContext context) => BetterStreamBuilder( stream: channel.lastMessageAtStream, initialData: channel.lastMessageAt, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); + builder: (context, data) { + if (data == null) { + return const Offstage(); } - final lastMessageAt = snapshot.data!.toLocal(); + final lastMessageAt = data.toLocal(); String stringDate; final now = DateTime.now(); @@ -218,59 +221,62 @@ class ChannelPreview extends StatelessWidget { ); } - Widget _buildLastMessage(BuildContext context) => - StreamBuilder?>( - stream: channel.state!.messagesStream, - initialData: channel.state!.messages, - builder: (context, snapshot) { - final lastMessage = snapshot.data - ?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); - if (lastMessage == null) { - return const SizedBox(); - } + Widget _buildLastMessage(BuildContext context) => Align( + alignment: Alignment.centerLeft, + child: BetterStreamBuilder?>( + stream: channel.state!.messagesStream, + initialData: channel.state!.messages, + builder: (context, data) { + final lastMessage = data + ?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted); + if (lastMessage == null) { + return const SizedBox(); + } - var text = lastMessage.text; - final parts = [ - ...lastMessage.attachments.map((e) { - if (e.type == 'image') { - return '📷'; - } else if (e.type == 'video') { - return 'đŸŽŦ'; - } else if (e.type == 'giphy') { - return '[GIF]'; - } - return e == lastMessage.attachments.last - ? (e.title ?? 'File') - : '${e.title ?? 'File'} , '; - }), - lastMessage.text ?? '', - ]; + var text = lastMessage.text; + final parts = [ + ...lastMessage.attachments.map((e) { + if (e.type == 'image') { + return '📷'; + } else if (e.type == 'video') { + return 'đŸŽŦ'; + } else if (e.type == 'giphy') { + return '[GIF]'; + } + return e == lastMessage.attachments.last + ? (e.title ?? 'File') + : '${e.title ?? 'File'} , '; + }), + lastMessage.text ?? '', + ]; - text = parts.join(' '); + text = parts.join(' '); - final chatThemeData = StreamChatTheme.of(context); - return Text.rich( - _getDisplayText( - text, - lastMessage.mentionedUsers, - lastMessage.attachments, - chatThemeData.channelPreviewTheme.subtitle?.copyWith( + final chatThemeData = StreamChatTheme.of(context); + return Text.rich( + _getDisplayText( + text, + lastMessage.mentionedUsers, + lastMessage.attachments, + chatThemeData.channelPreviewTheme.subtitle?.copyWith( + color: chatThemeData.channelPreviewTheme.subtitle?.color, + fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) + ? FontStyle.italic + : FontStyle.normal), + chatThemeData.channelPreviewTheme.subtitle?.copyWith( color: chatThemeData.channelPreviewTheme.subtitle?.color, fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) ? FontStyle.italic - : FontStyle.normal), - chatThemeData.channelPreviewTheme.subtitle?.copyWith( - color: chatThemeData.channelPreviewTheme.subtitle?.color, - fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) - ? FontStyle.italic - : FontStyle.normal, - fontWeight: FontWeight.bold, + : FontStyle.normal, + fontWeight: FontWeight.bold, + ), ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + ); + }, + ), ); TextSpan _getDisplayText( 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 7e4e7f8e..fbe7ffae 100644 --- a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart +++ b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart @@ -12,15 +12,11 @@ class ConnectionStatusBuilder extends StatelessWidget { const ConnectionStatusBuilder({ Key? key, required this.statusBuilder, - this.initialStatus = ConnectionStatus.disconnected, this.connectionStatusStream, this.errorBuilder, this.loadingBuilder, }) : super(key: key); - /// The connection status that will be used to create the initial snapshot. - final ConnectionStatus initialStatus; - /// The asynchronous computation to which this builder is currently connected. final Stream? connectionStatusStream; @@ -38,22 +34,18 @@ class ConnectionStatusBuilder extends StatelessWidget { Widget build(BuildContext context) { final stream = connectionStatusStream ?? StreamChat.of(context).client.wsConnectionStatusStream; - return StreamBuilder( - initialData: initialStatus, + final client = StreamChat.of(context).client; + return BetterStreamBuilder( + initialData: client.wsConnectionStatus, stream: stream, - builder: (context, snapshot) { - if (snapshot.hasError) { - if (errorBuilder != null) { - return errorBuilder!(context, snapshot.error); - } - return const Offstage(); + loadingBuilder: loadingBuilder, + errorBuilder: (context, error) { + if (errorBuilder != null) { + return errorBuilder!(context, error); } - if (!snapshot.hasData) { - if (loadingBuilder != null) return loadingBuilder!(context); - return const Offstage(); - } - return statusBuilder(context, snapshot.data!); + return const Offstage(); }, + builder: statusBuilder, ); } } diff --git a/packages/stream_chat_flutter/lib/src/date_divider.dart b/packages/stream_chat_flutter/lib/src/date_divider.dart index 185683be..8fff2ade 100644 --- a/packages/stream_chat_flutter/lib/src/date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/date_divider.dart @@ -55,7 +55,7 @@ class DateDivider extends StatelessWidget { child: Text( dayInfo, style: chatThemeData.textTheme.footnote.copyWith( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/deleted_message.dart b/packages/stream_chat_flutter/lib/src/deleted_message.dart index 9a4e0ae2..011b0c72 100644 --- a/packages/stream_chat_flutter/lib/src/deleted_message.dart +++ b/packages/stream_chat_flutter/lib/src/deleted_message.dart @@ -39,8 +39,8 @@ class DeletedMessage extends StatelessWidget { side: borderSide ?? BorderSide( color: Theme.of(context).brightness == Brightness.dark - ? chatThemeData.colorTheme.white.withAlpha(24) - : chatThemeData.colorTheme.black.withAlpha(24), + ? chatThemeData.colorTheme.barsBg.withAlpha(24) + : chatThemeData.colorTheme.textHighEmphasis.withAlpha(24), ), ), child: Padding( diff --git a/packages/stream_chat_flutter/lib/src/emoji/emoji.dart b/packages/stream_chat_flutter/lib/src/emoji/emoji.dart index 617e372a..38af85af 100644 --- a/packages/stream_chat_flutter/lib/src/emoji/emoji.dart +++ b/packages/stream_chat_flutter/lib/src/emoji/emoji.dart @@ -114325,6 +114325,9 @@ class Emoji { /// Get all Emojis static List all() => List.unmodifiable(_emojis); + static Iterable chars() => + _emojis.map((e) => e.char).whereType(); + /// Returns Emoji by [char] and character static Emoji? byChar(String char) { return _emojis.firstWhereOrNull((Emoji emoji) => emoji.char == char); diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 3b6ba3e2..5161c8e8 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -final _emojis = Emoji.all(); +final _emojiChars = Emoji.chars(); /// String extension extension StringExtension on String { @@ -17,10 +17,10 @@ extension StringExtension on String { /// 1 to 3 emojis: big size with no text bubble. /// 4+ emojis or emojis+text: standard size with text bubble. bool get isOnlyEmoji { + if (isEmpty) return false; + if (length > 3) return false; final characters = trim().characters; - if (characters.isEmpty) return false; - if (characters.length > 3) return false; - return characters.every((c) => _emojis.map((e) => e.char).contains(c)); + return characters.every(_emojiChars.contains); } } diff --git a/packages/stream_chat_flutter/lib/src/group_image.dart b/packages/stream_chat_flutter/lib/src/group_image.dart index c6778a91..3513bda2 100644 --- a/packages/stream_chat_flutter/lib/src/group_image.dart +++ b/packages/stream_chat_flutter/lib/src/group_image.dart @@ -51,7 +51,7 @@ class GroupImage extends StatelessWidget { constraints: constraints ?? streamChatTheme.ownMessageTheme.avatarTheme?.constraints, decoration: BoxDecoration( - color: streamChatTheme.colorTheme.accentBlue, + color: streamChatTheme.colorTheme.accentPrimary, ), child: Flex( direction: Axis.vertical, @@ -119,7 +119,7 @@ class GroupImage extends StatelessWidget { BorderRadius.zero) + BorderRadius.circular(selectionThickness), child: Container( - color: selectionColor ?? streamChatTheme.colorTheme.accentBlue, + color: selectionColor ?? streamChatTheme.colorTheme.accentPrimary, height: 64, width: 64, child: Padding( diff --git a/packages/stream_chat_flutter/lib/src/image_footer.dart b/packages/stream_chat_flutter/lib/src/image_footer.dart index 0a6571cc..4b32afd8 100644 --- a/packages/stream_chat_flutter/lib/src/image_footer.dart +++ b/packages/stream_chat_flutter/lib/src/image_footer.dart @@ -88,7 +88,7 @@ class _ImageFooterState extends State { context: context, removeTop: true, child: BottomAppBar( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -100,7 +100,7 @@ class _ImageFooterState extends State { IconButton( icon: StreamSvgIcon.iconShare( size: 24, - color: chatThemeData.colorTheme.black, + color: chatThemeData.colorTheme.textHighEmphasis, ), onPressed: () async { final attachment = @@ -144,7 +144,7 @@ class _ImageFooterState extends State { ), IconButton( icon: StreamSvgIcon.iconGrid( - color: chatThemeData.colorTheme.black, + color: chatThemeData.colorTheme.textHighEmphasis, ), onPressed: () => _showPhotosModal(context), ), @@ -160,7 +160,7 @@ class _ImageFooterState extends State { showModalBottomSheet( context: context, barrierColor: chatThemeData.colorTheme.overlay, - backgroundColor: chatThemeData.colorTheme.white, + backgroundColor: chatThemeData.colorTheme.barsBg, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -199,7 +199,7 @@ class _ImageFooterState extends State { alignment: Alignment.centerRight, child: IconButton( icon: StreamSvgIcon.close( - color: chatThemeData.colorTheme.black, + color: chatThemeData.colorTheme.textHighEmphasis, ), onPressed: () => Navigator.maybePop(context), ), @@ -262,7 +262,8 @@ class _ImageFooterState extends State { boxShadow: [ BoxShadow( blurRadius: 8, - color: chatThemeData.colorTheme.black + color: chatThemeData + .colorTheme.textHighEmphasis .withOpacity(0.3), ), ], diff --git a/packages/stream_chat_flutter/lib/src/image_header.dart b/packages/stream_chat_flutter/lib/src/image_header.dart index 434a73c5..92f4fa10 100644 --- a/packages/stream_chat_flutter/lib/src/image_header.dart +++ b/packages/stream_chat_flutter/lib/src/image_header.dart @@ -59,7 +59,7 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { leading: showBackButton ? IconButton( icon: StreamSvgIcon.close( - color: chatThemeData.colorTheme.black, + color: chatThemeData.colorTheme.textHighEmphasis, size: 24, ), onPressed: onBackPressed, @@ -70,7 +70,7 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { if (message.type != 'ephemeral') IconButton( icon: StreamSvgIcon.iconMenuPoint( - color: chatThemeData.colorTheme.black, + color: chatThemeData.colorTheme.textHighEmphasis, ), onPressed: () { _showMessageActionModalBottomSheet(context); @@ -111,6 +111,7 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { final channel = StreamChannel.of(context).channel; final result = await showDialog( + useRootNavigator: false, context: context, barrierColor: StreamChatTheme.of(context).colorTheme.overlay, builder: (context) => StreamChannel( diff --git a/packages/stream_chat_flutter/lib/src/info_tile.dart b/packages/stream_chat_flutter/lib/src/info_tile.dart index 1e935942..965d4fed 100644 --- a/packages/stream_chat_flutter/lib/src/info_tile.dart +++ b/packages/stream_chat_flutter/lib/src/info_tile.dart @@ -46,8 +46,8 @@ class InfoTile extends StatelessWidget { childAnchor: childAnchor ?? Alignment.bottomCenter, portal: Container( height: 25, - color: - backgroundColor ?? chatThemeData.colorTheme.grey.withOpacity(0.9), + color: backgroundColor ?? + chatThemeData.colorTheme.textLowEmphasis.withOpacity(0.9), child: Center( child: Text( message, diff --git a/packages/stream_chat_flutter/lib/src/media_list_view.dart b/packages/stream_chat_flutter/lib/src/media_list_view.dart index fe0070f9..77359f8b 100644 --- a/packages/stream_chat_flutter/lib/src/media_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/media_list_view.dart @@ -90,8 +90,8 @@ class _MediaListViewState extends State { ? 1.0 : 0.0, child: Container( - color: - chatThemeData.colorTheme.black.withOpacity(0.5), + color: chatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5), alignment: Alignment.topRight, padding: const EdgeInsets.only( top: 8, @@ -99,10 +99,11 @@ class _MediaListViewState extends State { ), child: CircleAvatar( radius: 12, - backgroundColor: chatThemeData.colorTheme.white, + backgroundColor: chatThemeData.colorTheme.barsBg, child: StreamSvgIcon.check( size: 24, - color: chatThemeData.colorTheme.black, + color: + chatThemeData.colorTheme.textHighEmphasis, ), ), ), @@ -124,7 +125,7 @@ class _MediaListViewState extends State { child: Text( media.videoDuration.format(), style: TextStyle( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/mention_tile.dart b/packages/stream_chat_flutter/lib/src/mention_tile.dart index 99b4b962..d9469215 100644 --- a/packages/stream_chat_flutter/lib/src/mention_tile.dart +++ b/packages/stream_chat_flutter/lib/src/mention_tile.dart @@ -76,7 +76,7 @@ class MentionTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: chatThemeData.textTheme.footnoteBold.copyWith( - color: chatThemeData.colorTheme.grey, + color: chatThemeData.colorTheme.textLowEmphasis, ), ), ], @@ -90,7 +90,7 @@ class MentionTile extends StatelessWidget { left: 8, ), child: StreamSvgIcon.mentions( - color: chatThemeData.colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index 1da4fcea..3e653187 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -16,6 +15,7 @@ class MessageActionsModal extends StatefulWidget { const MessageActionsModal({ Key? key, required this.message, + required this.messageWidget, required this.messageTheme, this.showReactions = true, this.showDeleteMessage = true, @@ -28,18 +28,15 @@ class MessageActionsModal extends StatefulWidget { this.showThreadReplyMessage = true, this.showFlagButton = true, this.showPinButton = true, - this.showPinHighlight = false, - this.showUserAvatar = DisplayWidget.show, this.editMessageInputBuilder, - this.messageShape, - this.attachmentShape, this.reverse = false, this.customActions = const [], - this.attachmentBorderRadiusGeometry, this.onCopyTap, - this.textBuilder, }) : super(key: key); + /// Widget that shows the message + final Widget messageWidget; + /// Builder for edit message final Widget Function(BuildContext, Message)? editMessageInputBuilder; @@ -85,30 +82,12 @@ class MessageActionsModal extends StatefulWidget { /// Flag for showing pin action final bool showPinButton; - /// Display Pin Highlight - final bool showPinHighlight; - /// Flag for reversing message final bool reverse; - /// [ShapeBorder] to apply to the widget - final ShapeBorder? messageShape; - - /// [ShapeBorder] to apply to attachment - final ShapeBorder? attachmentShape; - - /// Enum for displaying user avatar - final DisplayWidget showUserAvatar; - - /// [BorderRadius] for attachment border - final BorderRadius? attachmentBorderRadiusGeometry; - /// List of custom actions final List customActions; - /// Customize the MessageWidget textBuilder - final Widget Function(BuildContext context, Message message)? textBuilder; - @override _MessageActionsModalState createState() => _MessageActionsModalState(); } @@ -143,10 +122,94 @@ class _MessageActionsModalState extends State { ? 1 : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); - final hasFileAttachment = - widget.message.attachments.any((it) => it.type == 'file') == true; - final streamChatThemeData = StreamChatTheme.of(context); + + final numberOfReactions = streamChatThemeData.reactionIcons.length; + final shiftFactor = + numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0; + + final child = Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: widget.reverse + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (widget.showReactions && + (widget.message.status == MessageSendingStatus.sent)) + Align( + alignment: Alignment( + user?.id == widget.message.user?.id + ? (divFactor >= 1.0 + ? -0.2 - shiftFactor + : (1.2 - divFactor)) + : (divFactor >= 1.0 + ? 0.2 + shiftFactor + : -(1.2 - divFactor)), + 0), + child: ReactionPicker( + message: widget.message, + ), + ), + const SizedBox(height: 8), + IgnorePointer( + child: widget.messageWidget, + ), + const SizedBox(height: 8), + Padding( + padding: EdgeInsets.only( + left: widget.reverse ? 0 : 40, + ), + child: SizedBox( + width: mediaQueryData.size.width * 0.75, + child: Material( + color: streamChatThemeData.colorTheme.appBg, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.showReplyMessage && + widget.message.status == MessageSendingStatus.sent) + _buildReplyButton(context), + if (widget.showThreadReplyMessage && + (widget.message.status == + MessageSendingStatus.sent) && + widget.message.parentId == null) + _buildThreadReplyButton(context), + if (widget.showResendMessage) + _buildResendMessage(context), + if (widget.showEditMessage) _buildEditMessage(context), + if (widget.showCopyMessage) _buildCopyButton(context), + if (widget.showFlagButton) _buildFlagButton(context), + if (widget.showPinButton) _buildPinButton(context), + if (widget.showDeleteMessage) + _buildDeleteButton(context), + ...widget.customActions + .map((action) => _buildCustomAction( + context, + action, + )) + ].insertBetween( + Container( + height: 1, + color: streamChatThemeData.colorTheme.borders, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => Navigator.maybePop(context), @@ -168,136 +231,11 @@ class _MessageActionsModalState extends State { tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 300), curve: Curves.easeInOutBack, - builder: (context, val, snapshot) => Transform.scale( + builder: (context, val, child) => Transform.scale( scale: val, - child: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: widget.reverse - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - if (widget.showReactions && - (widget.message.status == - MessageSendingStatus.sent)) - Align( - alignment: Alignment( - user?.id == widget.message.user?.id - ? (divFactor >= 1.0 - ? -0.2 - : (1.2 - divFactor)) - : (divFactor >= 1.0 - ? 0.2 - : -(1.2 - divFactor)), - 0), - child: ReactionPicker( - message: widget.message, - ), - ), - const SizedBox(height: 8), - IgnorePointer( - child: MessageWidget( - key: const Key('MessageWidget'), - reverse: widget.reverse, - attachmentBorderRadiusGeometry: widget - .attachmentBorderRadiusGeometry - ?.mirrorBorderIfReversed( - reverse: !widget.reverse), - message: widget.message.copyWith( - text: widget.message.text!.length > 200 - // ignore: lines_longer_than_80_chars - ? '${widget.message.text!.substring(0, 200)}...' - : widget.message.text, - ), - messageTheme: widget.messageTheme, - showReactions: false, - showUsername: false, - showReplyMessage: false, - showUserAvatar: widget.showUserAvatar, - attachmentPadding: EdgeInsets.all( - hasFileAttachment ? 4 : 2, - ), - showTimestamp: false, - translateUserAvatar: false, - padding: const EdgeInsets.all(0), - textPadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: - widget.message.text!.isOnlyEmoji ? 0 : 16.0, - ), - showReactionPickerIndicator: - widget.showReactions && - (widget.message.status == - MessageSendingStatus.sent), - showSendingIndicator: false, - shape: widget.messageShape, - attachmentShape: widget.attachmentShape, - showPinHighlight: false, - textBuilder: widget.textBuilder, - ), - ), - const SizedBox(height: 8), - Padding( - padding: EdgeInsets.only( - left: widget.reverse ? 0 : 40, - ), - child: SizedBox( - width: mediaQueryData.size.width * 0.75, - child: Material( - color: streamChatThemeData.colorTheme.whiteSnow, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - if (widget.showReplyMessage && - widget.message.status == - MessageSendingStatus.sent) - _buildReplyButton(context), - if (widget.showThreadReplyMessage && - (widget.message.status == - MessageSendingStatus.sent) && - widget.message.parentId == null) - _buildThreadReplyButton(context), - if (widget.showResendMessage) - _buildResendMessage(context), - if (widget.showEditMessage) - _buildEditMessage(context), - if (widget.showCopyMessage) - _buildCopyButton(context), - if (widget.showFlagButton) - _buildFlagButton(context), - if (widget.showPinButton) - _buildPinButton(context), - if (widget.showDeleteMessage) - _buildDeleteButton(context), - ...widget.customActions - .map((action) => _buildCustomAction( - context, - action, - )) - ].insertBetween( - Container( - height: 1, - color: streamChatThemeData - .colorTheme.greyWhisper, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), + child: child, ), + child: child, ), ], ), @@ -332,7 +270,7 @@ class _MessageActionsModalState extends State { context, title: 'Flag Message', icon: StreamSvgIcon.flag( - color: streamChatThemeData.colorTheme.accentRed, + color: streamChatThemeData.colorTheme.accentError, size: 24, ), question: @@ -349,7 +287,7 @@ class _MessageActionsModalState extends State { await showInfoDialog( context, icon: StreamSvgIcon.flag( - color: theme.colorTheme.accentRed, + color: theme.colorTheme.accentError, size: 24, ), details: 'The message has been reported to a moderator.', @@ -357,11 +295,12 @@ class _MessageActionsModalState extends State { okText: 'OK', ); } catch (err) { - if (err is ApiError && json.decode(err.body ?? '{}')['code'] == 4) { + if (err is StreamChatNetworkError && + err.errorCode == ChatErrorCode.inputError) { await showInfoDialog( context, icon: StreamSvgIcon.flag( - color: theme.colorTheme.accentRed, + color: theme.colorTheme.accentError, size: 24, ), details: 'The message has been reported to a moderator.', @@ -378,13 +317,13 @@ class _MessageActionsModalState extends State { void _togglePin() async { final channel = StreamChannel.of(context).channel; + Navigator.pop(context); try { if (!widget.message.pinned) { await channel.pinMessage(widget.message); } else { await channel.unpinMessage(widget.message); } - Navigator.pop(context); } catch (e) { _showErrorAlert(); } @@ -398,7 +337,7 @@ class _MessageActionsModalState extends State { context, title: 'Delete message', icon: StreamSvgIcon.flag( - color: StreamChatTheme.of(context).colorTheme.accentRed, + color: StreamChatTheme.of(context).colorTheme.accentError, size: 24, ), question: 'Are you sure you want to permanently delete this\nmessage?', @@ -424,7 +363,7 @@ class _MessageActionsModalState extends State { showInfoDialog( context, icon: StreamSvgIcon.error( - color: StreamChatTheme.of(context).colorTheme.accentRed, + color: StreamChatTheme.of(context).colorTheme.accentError, size: 24, ), details: 'The operation couldn\'t be completed.', @@ -601,7 +540,7 @@ class _MessageActionsModalState extends State { child: Row( children: [ StreamSvgIcon.circleUp( - color: streamChatThemeData.colorTheme.accentBlue, + color: streamChatThemeData.colorTheme.accentPrimary, ), const SizedBox(width: 16), Text( @@ -629,48 +568,51 @@ class _MessageActionsModalState extends State { topRight: Radius.circular(16), ), ), - builder: (context) => StreamChannel( - channel: channel, - child: Flex( - direction: Axis.vertical, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon.edit( - color: streamChatThemeData.colorTheme.greyGainsboro, + builder: (context) => Padding( + padding: MediaQuery.of(context).viewInsets, + child: StreamChannel( + channel: channel, + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: StreamSvgIcon.edit( + color: streamChatThemeData.colorTheme.disabled, + ), ), - ), - const Text( - 'Edit Message', - style: TextStyle(fontWeight: FontWeight.bold), - ), - IconButton( - visualDensity: VisualDensity.compact, - icon: StreamSvgIcon.closeSmall(), - onPressed: Navigator.of(context).pop, - ), - ], + const Text( + 'Edit Message', + style: TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.closeSmall(), + onPressed: Navigator.of(context).pop, + ), + ], + ), ), - ), - if (widget.editMessageInputBuilder != null) - widget.editMessageInputBuilder!(context, widget.message) - else - MessageInput( - editMessage: widget.message, - preMessageSending: (m) { - FocusScope.of(context).unfocus(); - Navigator.pop(context); - return m; - }, - ), - ], + if (widget.editMessageInputBuilder != null) + widget.editMessageInputBuilder!(context, widget.message) + else + MessageInput( + editMessage: widget.message, + preMessageSending: (m) { + FocusScope.of(context).unfocus(); + Navigator.pop(context); + return m; + }, + ), + ], + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 0d2070e7..a0ff93cf 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -251,7 +251,6 @@ class MessageInputState extends State { late final FocusNode _focusNode; bool _inputEnabled = true; bool _messageIsPresent = false; - bool _animateContainer = true; bool _commandEnabled = false; OverlayEntry? _commandsOverlay, _mentionsOverlay, _emojiOverlay; late Iterable _emojiNames; @@ -268,6 +267,8 @@ class MessageInputState extends State { /// The editing controller passed to the input TextField late final TextEditingController textEditingController; + late StreamChatThemeData _streamChatTheme; + bool get _hasQuotedMessage => widget.quotedMessage != null; @override @@ -305,9 +306,10 @@ class MessageInputState extends State { @override Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - Widget child = Container( - color: streamChatThemeData.messageInputTheme.inputBackground, + Widget child = DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.messageInputTheme.inputBackground, + ), child: SafeArea( child: GestureDetector( onPanUpdate: (details) { @@ -332,7 +334,7 @@ class MessageInputState extends State { Padding( padding: const EdgeInsets.all(8), child: StreamSvgIcon.reply( - color: streamChatThemeData.colorTheme.greyGainsboro, + color: _streamChatTheme.colorTheme.disabled, ), ), const Text( @@ -390,65 +392,64 @@ class MessageInputState extends State { ], ); - Widget _buildDmCheckbox() { - final streamChatThemeData = StreamChatTheme.of(context); - return Row( - children: [ - Container( - height: 16, - width: 16, - foregroundDecoration: BoxDecoration( - border: _sendAsDm - ? null - : Border.all( - color: streamChatThemeData.colorTheme.black.withOpacity(.5), - width: 2, - ), - borderRadius: BorderRadius.circular(3), - ), - child: Center( - child: Material( + Widget _buildDmCheckbox() => Row( + children: [ + Container( + height: 16, + width: 16, + foregroundDecoration: BoxDecoration( + border: _sendAsDm + ? null + : Border.all( + color: _streamChatTheme.colorTheme.textHighEmphasis + .withOpacity(.5), + width: 2, + ), borderRadius: BorderRadius.circular(3), - color: _sendAsDm - ? streamChatThemeData.colorTheme.accentBlue - : streamChatThemeData.colorTheme.white, - child: InkWell( - onTap: () { - setState(() { - _sendAsDm = !_sendAsDm; - }); - }, - child: AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 300), - crossFadeState: _sendAsDm - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: StreamSvgIcon.check( - size: 16, - color: streamChatThemeData.colorTheme.white, - ), - secondChild: const SizedBox( - height: 16, - width: 16, + ), + child: Center( + child: Material( + borderRadius: BorderRadius.circular(3), + color: _sendAsDm + ? _streamChatTheme.colorTheme.accentPrimary + : _streamChatTheme.colorTheme.barsBg, + child: InkWell( + onTap: () { + setState(() { + _sendAsDm = !_sendAsDm; + }); + }, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + crossFadeState: _sendAsDm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: StreamSvgIcon.check( + size: 16, + color: _streamChatTheme.colorTheme.barsBg, + ), + secondChild: const SizedBox( + height: 16, + width: 16, + ), ), ), ), ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - 'Also send as direct message', - style: streamChatThemeData.textTheme.footnote.copyWith( - color: streamChatThemeData.colorTheme.black.withOpacity(0.5), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Also send as direct message', + style: _streamChatTheme.textTheme.footnote.copyWith( + color: _streamChatTheme.colorTheme.textHighEmphasis + .withOpacity(0.5), + ), ), ), - ), - ], - ); - } + ], + ); Widget _animateSendButton(BuildContext context) { final sendButton = widget.activeSendButton != null @@ -463,8 +464,7 @@ class MessageInputState extends State { : CrossFadeState.showSecond, firstChild: sendButton, secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), - duration: - StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration!, + duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!, alignment: Alignment.center, ); } @@ -478,16 +478,18 @@ class MessageInputState extends State { ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: IconButton( - onPressed: () => setState(() => _actionsShrunk = false), + onPressed: () { + if (_actionsShrunk) { + setState(() => _actionsShrunk = false); + } + }, icon: Transform.rotate( angle: (widget.actionsLocation == ActionsLocation.right || widget.actionsLocation == ActionsLocation.rightInside) ? pi : 0, child: StreamSvgIcon.emptyCircleLeft( - color: StreamChatTheme.of(context) - .messageInputTheme - .expandButtonColor, + color: _streamChatTheme.messageInputTheme.expandButtonColor, ), ), padding: const EdgeInsets.all(0), @@ -522,7 +524,6 @@ class MessageInputState extends State { } Expanded _buildTextInput(BuildContext context) { - final theme = StreamChatTheme.of(context); final margin = (widget.sendButtonLocation == SendButtonLocation.inside ? const EdgeInsets.only(right: 8) : EdgeInsets.zero) + @@ -530,49 +531,46 @@ class MessageInputState extends State { ? const EdgeInsets.only(left: 8) : EdgeInsets.zero); return Expanded( - child: Center( - child: Container( - clipBehavior: Clip.antiAlias, - margin: margin, - decoration: BoxDecoration( - borderRadius: theme.messageInputTheme.borderRadius, - gradient: _focusNode.hasFocus - ? theme.messageInputTheme.activeBorderGradient - : theme.messageInputTheme.idleBorderGradient, - ), - child: Padding( - padding: const EdgeInsets.all(1.5), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: theme.messageInputTheme.borderRadius, - color: theme.messageInputTheme.inputBackground, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyToMessage(), - _buildAttachments(), - LimitedBox( - maxHeight: widget.maxHeight, - child: TextField( - key: const Key('messageInputText'), - enabled: _inputEnabled, - maxLines: null, - onSubmitted: (_) => sendMessage(), - keyboardType: widget.keyboardType, - controller: textEditingController, - focusNode: _focusNode, - style: theme.messageInputTheme.inputTextStyle, - autofocus: widget.autofocus, - textAlignVertical: TextAlignVertical.center, - decoration: _getInputDecoration(), - textCapitalization: TextCapitalization.sentences, - ), - ) - ], - ), + child: Container( + clipBehavior: Clip.hardEdge, + margin: margin, + decoration: BoxDecoration( + borderRadius: _streamChatTheme.messageInputTheme.borderRadius, + gradient: _focusNode.hasFocus + ? _streamChatTheme.messageInputTheme.activeBorderGradient + : _streamChatTheme.messageInputTheme.idleBorderGradient, + ), + child: Padding( + padding: const EdgeInsets.all(1.5), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: _streamChatTheme.messageInputTheme.borderRadius, + color: _streamChatTheme.messageInputTheme.inputBackground, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyToMessage(), + _buildAttachments(), + LimitedBox( + maxHeight: widget.maxHeight, + child: TextField( + key: const Key('messageInputText'), + enabled: _inputEnabled, + maxLines: null, + onSubmitted: (_) => sendMessage(), + keyboardType: widget.keyboardType, + controller: textEditingController, + focusNode: _focusNode, + style: _streamChatTheme.messageInputTheme.inputTextStyle, + autofocus: widget.autofocus, + textAlignVertical: TextAlignVertical.center, + decoration: _getInputDecoration(), + textCapitalization: TextCapitalization.sentences, + ), + ) + ], ), ), ), @@ -581,13 +579,12 @@ class MessageInputState extends State { } InputDecoration _getInputDecoration() { - final theme = StreamChatTheme.of(context); - final passedDecoration = theme.messageInputTheme.inputDecoration; + final passedDecoration = _streamChatTheme.messageInputTheme.inputDecoration; return InputDecoration( isDense: true, hintText: _getHint(), - hintStyle: theme.messageInputTheme.inputTextStyle!.copyWith( - color: theme.colorTheme.grey, + hintStyle: _streamChatTheme.messageInputTheme.inputTextStyle!.copyWith( + color: _streamChatTheme.colorTheme.textLowEmphasis, ), border: const OutlineInputBorder( borderSide: BorderSide( @@ -625,7 +622,7 @@ class MessageInputState extends State { constraints: BoxConstraints.tight(const Size(64, 24)), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - color: theme.colorTheme.accentBlue, + color: _streamChatTheme.colorTheme.accentPrimary, ), alignment: Alignment.center, child: Row( @@ -637,7 +634,8 @@ class MessageInputState extends State { ), Text( _chosenCommand?.name.toUpperCase() ?? '', - style: theme.textTheme.footnoteBold.copyWith( + style: + _streamChatTheme.textTheme.footnoteBold.copyWith( color: Colors.white, ), ), @@ -703,7 +701,10 @@ class MessageInputState extends State { if (!mounted) { return; } - StreamChannel.of(context).channel.keyStroke().catchError((e) {}); + StreamChannel.of(context) + .channel + .keyStroke(widget.parentMessage?.id) + .catchError((e) {}); setState(() { _messageIsPresent = s.trim().isNotEmpty; @@ -799,7 +800,7 @@ class MessageInputState extends State { setState(() { _commandEnabled = true; }); - _commandsOverlay!.remove(); + _commandsOverlay?.remove(); _commandsOverlay = null; } else { _commandsOverlay = _buildCommandsOverlayEntry(); @@ -828,119 +829,113 @@ class MessageInputState extends State { final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; + final child = Padding( + padding: const EdgeInsets.all(8), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + color: _streamChatTheme.colorTheme.barsBg, + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(400)), + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.barsBg, + borderRadius: BorderRadius.circular(8)), + child: ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + if (commands.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + child: StreamSvgIcon.lightning( + color: _streamChatTheme.colorTheme.accentPrimary, + ), + ), + Text( + 'Instant Commands', + style: TextStyle( + color: _streamChatTheme.colorTheme.textHighEmphasis + .withOpacity(.5), + ), + ) + ], + ), + ), + const SizedBox( + height: 10, + ), + ...commands + .map( + (c) => InkWell( + onTap: () { + _setCommand(c); + }, + child: SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox( + width: 16, + ), + _buildCommandIcon(c.name), + const SizedBox( + width: 8, + ), + Text.rich( + TextSpan( + text: c.name.capitalize(), + style: const TextStyle( + fontWeight: FontWeight.bold), + children: [ + TextSpan( + text: ' /${c.name} ${c.args}', + style: _streamChatTheme.textTheme.body + .copyWith( + // ignore: lines_longer_than_80_chars + color: _streamChatTheme + // ignore: lines_longer_than_80_chars + .colorTheme + .textLowEmphasis, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ) + .toList(), + ], + ), + ), + ), + ); return OverlayEntry( builder: (context) => Positioned( bottom: size.height + MediaQuery.of(context).viewInsets.bottom, left: 0, right: 0, child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutExpo, - builder: (context, val, wid) { - final streamChatThemeData = StreamChatTheme.of(context); - return Transform.scale( - scale: val, - child: Padding( - padding: const EdgeInsets.all(8), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - color: streamChatThemeData.colorTheme.white, - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose( - const Size.fromHeight(400)), - decoration: BoxDecoration( - color: streamChatThemeData.colorTheme.white, - borderRadius: BorderRadius.circular(8)), - child: ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: [ - if (commands.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - ), - child: StreamSvgIcon.lightning( - color: streamChatThemeData - .colorTheme.accentBlue, - ), - ), - Text( - 'Instant Commands', - style: TextStyle( - color: streamChatThemeData - .colorTheme.black - .withOpacity(.5), - ), - ) - ], - ), - ), - const SizedBox( - height: 10, - ), - ...commands - .map( - (c) => InkWell( - onTap: () { - _setCommand(c); - }, - child: SizedBox( - height: 40, - child: Row( - children: [ - const SizedBox( - width: 16, - ), - _buildCommandIcon(c.name), - const SizedBox( - width: 8, - ), - Text.rich( - TextSpan( - text: c.name.capitalize(), - style: const TextStyle( - fontWeight: - FontWeight.bold), - children: [ - TextSpan( - text: - ' /${c.name} ${c.args}', - style: streamChatThemeData - .textTheme.body - .copyWith( - // ignore: lines_longer_than_80_chars - color: streamChatThemeData - // ignore: lines_longer_than_80_chars - .colorTheme - .grey, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ) - .toList(), - ], - ), - ), - ), - ), - ); - }), + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutExpo, + builder: (context, val, child) => Transform.scale( + scale: val, + child: child, + ), + child: child, + ), )); } @@ -948,41 +943,44 @@ class MessageInputState extends State { final _attachmentContainsFile = _attachments.values.any((it) => it.type == 'file'); - final chatThemeData = StreamChatTheme.of(context); Color _getIconColor(int index) { - final streamChatThemeData = chatThemeData; + final streamChatThemeData = _streamChatTheme; switch (index) { case 0: return _attachments.isEmpty - ? streamChatThemeData.colorTheme.accentBlue + ? streamChatThemeData.colorTheme.accentPrimary : (!_attachmentContainsFile - ? streamChatThemeData.colorTheme.accentBlue - : streamChatThemeData.colorTheme.black.withOpacity(0.2)); + ? streamChatThemeData.colorTheme.accentPrimary + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.2)); case 1: return _attachmentContainsFile - ? streamChatThemeData.colorTheme.accentBlue + ? streamChatThemeData.colorTheme.accentPrimary : (_attachments.isEmpty - ? streamChatThemeData.colorTheme.black.withOpacity(0.5) - : streamChatThemeData.colorTheme.black.withOpacity(0.2)); + ? streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.2)); case 2: return _attachmentContainsFile && _attachments.isNotEmpty - ? streamChatThemeData.colorTheme.black.withOpacity(0.2) - : streamChatThemeData.colorTheme.black.withOpacity(0.5); + ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); case 3: return _attachmentContainsFile && _attachments.isNotEmpty - ? streamChatThemeData.colorTheme.black.withOpacity(0.2) - : streamChatThemeData.colorTheme.black.withOpacity(0.5); + ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); default: return Colors.black; } } return AnimatedContainer( - duration: - _animateContainer ? const Duration(milliseconds: 300) : Duration.zero, + duration: const Duration(milliseconds: 300), height: _openFilePickerSection ? _filePickerSize : 0, child: Material( - color: chatThemeData.colorTheme.whiteSmoke, + color: _streamChatTheme.colorTheme.inputBg, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1037,16 +1035,15 @@ class MessageInputState extends State { GestureDetector( onVerticalDragUpdate: (update) { setState(() { - _animateContainer = false; _filePickerSize = (_filePickerSize - update.delta.dy).clamp( _kMinMediaPickerSize, MediaQuery.of(context).size.height / 1.7, ); }); }, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: _streamChatTheme.colorTheme.barsBg, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), @@ -1057,12 +1054,14 @@ class MessageInputState extends State { child: Center( child: Padding( padding: const EdgeInsets.all(8), - child: Container( + child: SizedBox( width: 40, height: 4, - decoration: BoxDecoration( - color: chatThemeData.colorTheme.whiteSmoke, - borderRadius: BorderRadius.circular(4), + child: DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.inputBg, + borderRadius: BorderRadius.circular(4), + ), ), ), ), @@ -1072,13 +1071,14 @@ class MessageInputState extends State { ), if (_openFilePickerSection) Expanded( - child: Container( + child: DecoratedBox( decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, + color: _streamChatTheme.colorTheme.barsBg, borderRadius: BorderRadius.circular(8), ), child: _PickerWidget( filePickerIndex: _filePickerIndex, + streamChatTheme: _streamChatTheme, containsFile: _attachmentContainsFile, selectedMedias: _attachments.keys.toList(), onAddMoreFilesClick: pickFile, @@ -1150,7 +1150,6 @@ class MessageInputState extends State { } Widget _buildCommandIcon(String iconType) { - final chatThemeData = StreamChatTheme.of(context); switch (iconType) { case 'giphy': return CircleAvatar( @@ -1161,7 +1160,7 @@ class MessageInputState extends State { ); case 'ban': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.iconUserDelete( size: 16, @@ -1170,7 +1169,7 @@ class MessageInputState extends State { ); case 'flag': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.flag( size: 14, @@ -1179,7 +1178,7 @@ class MessageInputState extends State { ); case 'imgur': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: ClipOval( child: StreamSvgIcon.imgur( @@ -1189,7 +1188,7 @@ class MessageInputState extends State { ); case 'mute': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.mute( size: 16, @@ -1198,7 +1197,7 @@ class MessageInputState extends State { ); case 'unban': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.userAdd( size: 16, @@ -1207,7 +1206,7 @@ class MessageInputState extends State { ); case 'unmute': return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.volumeUp( size: 16, @@ -1216,7 +1215,7 @@ class MessageInputState extends State { ); default: return CircleAvatar( - backgroundColor: chatThemeData.colorTheme.accentBlue, + backgroundColor: _streamChatTheme.colorTheme.accentPrimary, radius: 12, child: StreamSvgIcon.lightning( size: 16, @@ -1253,7 +1252,70 @@ class MessageInputState extends State { // ignore: cast_nullable_to_non_nullable final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; + final child = Card( + margin: const EdgeInsets.all(8), + elevation: 2, + color: _streamChatTheme.colorTheme.barsBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(240)), + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.barsBg, + ), + child: FutureBuilder>( + future: queryMembers ?? Future.value(members), + initialData: members, + builder: (context, snapshot) => ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + const SizedBox( + height: 8, + ), + ...snapshot.data! + .where((it) => it.user != null) + .map( + (m) => Material( + color: _streamChatTheme.colorTheme.barsBg, + child: InkWell( + onTap: () { + if (m.user != null) { + _mentionedUsers.add(m.user!); + } + splits[splits.length - 1] = m.user!.name; + final rejoin = splits.join('@'); + + textEditingController.value = TextEditingValue( + text: rejoin + + textEditingController.text.substring( + textEditingController.selection.start), + selection: TextSelection.collapsed( + offset: rejoin.length, + ), + ); + _debounce!.cancel(); + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + }, + child: widget.mentionsTileBuilder != null + ? widget.mentionsTileBuilder!(context, m) + : MentionTile(m), + ), + ), + ) + .toList(), + const SizedBox( + height: 8, + ), + ], + ), + ), + ), + ); return OverlayEntry( builder: (context) => Positioned( bottom: size.height + MediaQuery.of(context).viewInsets.bottom, @@ -1263,78 +1325,11 @@ class MessageInputState extends State { tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 300), curve: Curves.easeInOutExpo, - builder: (context, val, wid) { - final chatThemeData = StreamChatTheme.of(context); - return Transform.scale( - scale: val, - child: Card( - margin: const EdgeInsets.all(8), - elevation: 2, - color: chatThemeData.colorTheme.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose(const Size.fromHeight(240)), - decoration: BoxDecoration( - color: chatThemeData.colorTheme.white, - ), - child: FutureBuilder>( - future: queryMembers ?? Future.value(members), - initialData: members, - builder: (context, snapshot) => ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: [ - const SizedBox( - height: 8, - ), - ...snapshot.data! - .where((it) => it.user != null) - .map( - (m) => Material( - color: chatThemeData.colorTheme.white, - child: InkWell( - onTap: () { - if (m.user != null) { - _mentionedUsers.add(m.user!); - } - - splits[splits.length - 1] = m.user!.name; - final rejoin = splits.join('@'); - - textEditingController.value = - TextEditingValue( - text: rejoin + - textEditingController.text.substring( - textEditingController - .selection.start), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); - _debounce!.cancel(); - _mentionsOverlay?.remove(); - _mentionsOverlay = null; - }, - child: widget.mentionsTileBuilder != null - ? widget.mentionsTileBuilder!(context, m) - : MentionTile(m), - ), - ), - ) - .toList(), - const SizedBox( - height: 8, - ), - ], - ), - ), - ), - ), - ); - }, + builder: (context, val, child) => Transform.scale( + scale: val, + child: child, + ), + child: child, ), ), ); @@ -1363,88 +1358,89 @@ class MessageInputState extends State { final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; - return OverlayEntry(builder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return Positioned( - bottom: size.height + MediaQuery.of(context).viewInsets.bottom, - left: 0, - right: 0, - child: Card( - margin: const EdgeInsets.all(8), - elevation: 2, - color: chatThemeData.colorTheme.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - clipBehavior: Clip.antiAlias, - child: Container( - constraints: BoxConstraints.loose(const Size.fromHeight(200)), - decoration: BoxDecoration( - boxShadow: const [ - BoxShadow( - spreadRadius: -8, - blurRadius: 5, - offset: Offset(0, -4), + return OverlayEntry( + builder: (context) => Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: Card( + margin: const EdgeInsets.all(8), + elevation: 2, + color: _streamChatTheme.colorTheme.barsBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), - ], - color: chatThemeData.colorTheme.white, - ), - child: ListView.builder( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - itemCount: emojis.length + 1, - itemBuilder: (context, i) { - if (i == 0) { - return Padding( - padding: const EdgeInsets.only(left: 8, top: 8), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: StreamSvgIcon.smile( - color: chatThemeData.colorTheme.accentBlue, + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(const Size.fromHeight(200)), + decoration: BoxDecoration( + boxShadow: const [ + BoxShadow( + spreadRadius: -8, + blurRadius: 5, + offset: Offset(0, -4), + ), + ], + color: _streamChatTheme.colorTheme.barsBg, + ), + child: ListView.builder( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + itemCount: emojis.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: Row( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8), + child: StreamSvgIcon.smile( + color: _streamChatTheme + .colorTheme.accentPrimary, + ), + ), + Flexible( + child: Text( + 'Emoji matching "$query"', + style: TextStyle( + color: _streamChatTheme + .colorTheme.textHighEmphasis + .withOpacity(.5), + ), + ), + ) + ], + ), + ); + } + + final emoji = emojis.elementAt(i - 1)!; + final themeData = Theme.of(context); + return ListTile( + title: SubstringHighlight( + text: + // ignore: lines_longer_than_80_chars + "${emoji.char} ${emoji.name!.replaceAll('_', ' ')}", + term: query, + textStyleHighlight: + themeData.textTheme.headline6!.copyWith( + fontSize: 14.5, + fontWeight: FontWeight.bold, + ), + textStyle: themeData.textTheme.headline6!.copyWith( + fontSize: 14.5, ), ), - Flexible( - child: Text( - 'Emoji matching "$query"', - style: TextStyle( - color: chatThemeData.colorTheme.black - .withOpacity(.5), - ), - ), - ) - ], - ), - ); - } - - final emoji = emojis.elementAt(i - 1)!; - final themeData = Theme.of(context); - return ListTile( - title: SubstringHighlight( - text: - // ignore: lines_longer_than_80_chars - "${emoji.char} ${emoji.name!.replaceAll('_', ' ')}", - term: query, - textStyleHighlight: - themeData.textTheme.headline6!.copyWith( - fontSize: 14.5, - fontWeight: FontWeight.bold, - ), - textStyle: themeData.textTheme.headline6!.copyWith( - fontSize: 14.5, - ), - ), - onTap: () { - _chooseEmoji(splits, emoji); - }, - ); - }), - ), - ), - ); - }); + onTap: () { + _chooseEmoji(splits, emoji); + }, + ); + }), + ), + ), + )); } void _chooseEmoji(List splits, Emoji emoji) { @@ -1483,7 +1479,7 @@ class MessageInputState extends State { reverse: true, showBorder: !containsUrl, message: widget.quotedMessage!, - messageTheme: StreamChatTheme.of(context).otherMessageTheme, + messageTheme: _streamChatTheme.otherMessageTheme, padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), ); } @@ -1568,32 +1564,30 @@ class MessageInputState extends State { ); } - Widget _buildRemoveButton(Attachment attachment) { - final chatThemeData = StreamChatTheme.of(context); - return SizedBox( - height: 24, - width: 24, - child: RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - hoverElevation: 0, - onPressed: () { - setState(() => _attachments.remove(attachment.id)); - }, - fillColor: chatThemeData.colorTheme.black.withOpacity(.5), - child: Center( - child: StreamSvgIcon.close( - size: 24, - color: chatThemeData.colorTheme.white, + Widget _buildRemoveButton(Attachment attachment) => SizedBox( + height: 24, + width: 24, + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + hoverElevation: 0, + onPressed: () { + setState(() => _attachments.remove(attachment.id)); + }, + fillColor: + _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(.5), + child: Center( + child: StreamSvgIcon.close( + size: 24, + color: _streamChatTheme.colorTheme.barsBg, + ), ), ), - ), - ); - } + ); Widget _buildAttachment(Attachment attachment) { if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) == @@ -1623,18 +1617,15 @@ class MessageInputState extends State { fit: BoxFit.cover, errorWidget: (_, obj, trace) => getFileTypeImage(attachment.extraData['other'] as String?), - progressIndicatorBuilder: (context, _, progress) { - final chatThemeData = StreamChatTheme.of(context); - return Shimmer.fromColors( - baseColor: chatThemeData.colorTheme.greyGainsboro, - highlightColor: chatThemeData.colorTheme.whiteSmoke, - child: Image.asset( - 'images/placeholder.png', - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ), - ); - }, + placeholder: (context, _) => Shimmer.fromColors( + baseColor: _streamChatTheme.colorTheme.disabled, + highlightColor: _streamChatTheme.colorTheme.inputBg, + child: Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', + ), + ), ); case 'video': return Stack( @@ -1666,14 +1657,13 @@ class MessageInputState extends State { Widget _buildCommandButton() { final s = textEditingController.text.trim(); - final chatThemeData = StreamChatTheme.of(context); return IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty - ? chatThemeData.colorTheme.greyGainsboro + ? _streamChatTheme.colorTheme.disabled : (_commandsOverlay != null - ? chatThemeData.messageInputTheme.actionButtonColor - : chatThemeData.messageInputTheme.actionButtonIdleColor), + ? _streamChatTheme.messageInputTheme.actionButtonColor + : _streamChatTheme.messageInputTheme.actionButtonIdleColor), ), padding: const EdgeInsets.all(0), constraints: const BoxConstraints.tightFor( @@ -1684,7 +1674,6 @@ class MessageInputState extends State { onPressed: () async { if (_openFilePickerSection) { setState(() { - _animateContainer = false; _openFilePickerSection = false; _filePickerSize = _kMinMediaPickerSize; }); @@ -1708,40 +1697,36 @@ class MessageInputState extends State { ); } - Widget _buildAttachmentButton() { - final chatThemeData = StreamChatTheme.of(context); - return IconButton( - icon: StreamSvgIcon.attach( - color: _openFilePickerSection - ? chatThemeData.messageInputTheme.actionButtonColor - : chatThemeData.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() => IconButton( + icon: StreamSvgIcon.attach( + color: _openFilePickerSection + ? _streamChatTheme.messageInputTheme.actionButtonColor + : _streamChatTheme.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(() { - _animateContainer = true; - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); - } else { - showAttachmentModal(); - } - }, - ); - } + if (_openFilePickerSection) { + setState(() { + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + } else { + showAttachmentModal(); + } + }, + ); /// Show the attachment modal, making the user choose where to /// pick a media from @@ -1948,8 +1933,7 @@ class MessageInputState extends State { padding: const EdgeInsets.all(8), child: StreamSvgIcon( assetName: _getIdleSendIcon(), - color: - StreamChatTheme.of(context).messageInputTheme.sendButtonIdleColor, + color: _streamChatTheme.messageInputTheme.sendButtonIdleColor, ), ); @@ -1965,8 +1949,7 @@ class MessageInputState extends State { ), icon: StreamSvgIcon( assetName: _getSendIcon(), - color: - StreamChatTheme.of(context).messageInputTheme.sendButtonColor, + color: _streamChatTheme.messageInputTheme.sendButtonColor, ), ), ); @@ -2083,9 +2066,8 @@ class MessageInputState extends State { StreamSubscription? _keyboardListener; void _showErrorAlert(String description) { - final chatThemeData = StreamChatTheme.of(context); showModalBottomSheet( - backgroundColor: chatThemeData.colorTheme.white, + backgroundColor: _streamChatTheme.colorTheme.barsBg, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -2099,7 +2081,7 @@ class MessageInputState extends State { height: 26, ), StreamSvgIcon.error( - color: chatThemeData.colorTheme.accentRed, + color: _streamChatTheme.colorTheme.accentError, size: 24, ), const SizedBox( @@ -2107,7 +2089,7 @@ class MessageInputState extends State { ), Text( 'Something went wrong', - style: chatThemeData.textTheme.headlineBold, + style: _streamChatTheme.textTheme.headlineBold, ), const SizedBox( height: 7, @@ -2123,7 +2105,8 @@ class MessageInputState extends State { height: 36, ), Container( - color: chatThemeData.colorTheme.black.withOpacity(.08), + color: + _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(.08), height: 1, ), Row( @@ -2135,8 +2118,8 @@ class MessageInputState extends State { }, child: Text( 'OK', - style: chatThemeData.textTheme.bodyBold - .copyWith(color: chatThemeData.colorTheme.accentBlue), + style: _streamChatTheme.textTheme.bodyBold.copyWith( + color: _streamChatTheme.colorTheme.accentPrimary), ), ), ], @@ -2169,6 +2152,7 @@ class MessageInputState extends State { @override void didChangeDependencies() { + _streamChatTheme = StreamChatTheme.of(context); if (widget.editMessage != null && !_initialized) { FocusScope.of(context).requestFocus(_focusNode); _initialized = true; @@ -2233,6 +2217,7 @@ class _PickerWidget extends StatefulWidget { required this.selectedMedias, required this.onAddMoreFilesClick, required this.onMediaSelected, + required this.streamChatTheme, }) : super(key: key); final int filePickerIndex; @@ -2240,6 +2225,7 @@ class _PickerWidget extends StatefulWidget { final List selectedMedias; final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; final void Function(AssetEntity) onMediaSelected; + final StreamChatThemeData streamChatTheme; @override __PickerWidgetState createState() => __PickerWidgetState(); @@ -2266,7 +2252,6 @@ class __PickerWidgetState extends State<_PickerWidget> { return const Center(child: CircularProgressIndicator()); } - final chatThemeData = StreamChatTheme.of(context); if (snapshot.data!) { if (widget.containsFile) { return GestureDetector( @@ -2275,12 +2260,12 @@ class __PickerWidgetState extends State<_PickerWidget> { }, child: Container( constraints: const BoxConstraints.expand(), - color: chatThemeData.colorTheme.whiteSmoke, + color: widget.streamChatTheme.colorTheme.inputBg, alignment: Alignment.center, child: Text( 'Add more files', style: TextStyle( - color: chatThemeData.colorTheme.accentBlue, + color: widget.streamChatTheme.colorTheme.accentPrimary, fontWeight: FontWeight.bold, ), ), @@ -2298,7 +2283,7 @@ class __PickerWidgetState extends State<_PickerWidget> { PhotoManager.openSetting(); }, child: Container( - color: chatThemeData.colorTheme.whiteSmoke, + color: widget.streamChatTheme.colorTheme.inputBg, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2307,21 +2292,22 @@ class __PickerWidgetState extends State<_PickerWidget> { 'svgs/icon_picture_empty_state.svg', package: 'stream_chat_flutter', height: 140, - color: chatThemeData.colorTheme.greyGainsboro, + color: widget.streamChatTheme.colorTheme.disabled, ), Text( // ignore: lines_longer_than_80_chars 'Please enable access to your photos \nand videos so you can share them with friends.', - style: chatThemeData.textTheme.body - .copyWith(color: chatThemeData.colorTheme.grey), + style: widget.streamChatTheme.textTheme.body.copyWith( + color: + widget.streamChatTheme.colorTheme.textLowEmphasis), textAlign: TextAlign.center, ), const SizedBox(height: 6), Center( child: Text( 'Allow access to your gallery', - style: chatThemeData.textTheme.bodyBold.copyWith( - color: chatThemeData.colorTheme.accentBlue, + style: widget.streamChatTheme.textTheme.bodyBold.copyWith( + color: widget.streamChatTheme.colorTheme.accentPrimary, ), ), ), 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 dfab3630..6e61612b 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -1,7 +1,9 @@ +// ignore_for_file: lines_longer_than_80_chars import 'dart:async'; -import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:rxdart/rxdart.dart'; @@ -17,16 +19,22 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:visibility_detector/visibility_detector.dart'; /// Widget builder for message +/// [defaultMessageWidget] is the default [MessageWidget] configuration +/// Use [defaultMessageWidget.copyWith] to easily customize it typedef MessageBuilder = Widget Function( BuildContext, MessageDetails, List, + MessageWidget defaultMessageWidget, ); /// Widget builder for parent message +/// [defaultMessageWidget] is the default [MessageWidget] configuration +/// Use [defaultMessageWidget.copyWith] to easily customize it typedef ParentMessageBuilder = Widget Function( BuildContext, Message?, + MessageWidget defaultMessageWidget, ); /// Widget builder for system message @@ -54,12 +62,12 @@ typedef ReplyTapCallback = void Function(Message); class MessageDetails { /// Constructor for creating [MessageDetails] MessageDetails( - BuildContext context, + String currentUserId, this.message, List messages, this.index, ) { - isMyMessage = message.user?.id == StreamChat.of(context).user?.id; + isMyMessage = message.user?.id == currentUserId; isLastUser = index + 1 < messages.length && message.user?.id == messages[index + 1].user?.id; isNextUser = @@ -67,19 +75,19 @@ class MessageDetails { } /// True if the message belongs to the current user - bool? isMyMessage; + late final bool isMyMessage; /// True if the user message is the same of the previous message - bool? isLastUser; + late final bool isLastUser; /// True if the user message is the same of the next message - bool? isNextUser; + late final bool isNextUser; /// The message - Message message; + final Message message; /// The index of the message - int index; + final int index; } /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_listview.png) @@ -146,6 +154,8 @@ class MessageListView extends StatefulWidget { this.messageHighlightColor, this.onShowMessage, this.showConnectionStateTile = false, + this.headerBuilder, + this.footerBuilder, this.loadingBuilder, this.emptyBuilder, this.systemMessageBuilder, @@ -160,6 +170,9 @@ class MessageListView extends StatefulWidget { this.pinPermissions = const [], this.textBuilder, this.usernameBuilder, + this.showFloatingDateDivider = true, + this.threadSeparatorBuilder, + this.messageListController, }) : super(key: key); /// Function used to build a custom message widget @@ -226,9 +239,18 @@ class MessageListView extends StatefulWidget { /// Flag for showing tile on header final bool showConnectionStateTile; + /// Flag for showing the floating date divider + final bool showFloatingDateDivider; + /// Function called when messages are fetched final Widget Function(BuildContext, List)? messageListBuilder; + /// Function used to build a header widget + final WidgetBuilder? headerBuilder; + + /// Function used to build a footer widget + final WidgetBuilder? footerBuilder; + /// Function used to build a loading widget final WidgetBuilder? loadingBuilder; @@ -272,6 +294,13 @@ class MessageListView extends StatefulWidget { /// A List of user types that have permission to pin messages final List pinPermissions; + /// Builder used to build the thread separator in case it's a thread view + final WidgetBuilder? threadSeparatorBuilder; + + /// A [MessageListController] allows pagination. + /// Use [ChannelListController.paginateData] pagination. + final MessageListController? messageListController; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -281,8 +310,10 @@ class _MessageListViewState extends State { void Function(Message)? _onThreadTap; bool _showScrollToBottom = false; late final ItemPositionsListener _itemPositionListener; + late final Stream> _itemPositionStream; int? _messageListLength; StreamChannelState? streamChannel; + late StreamChatThemeData _streamTheme; int? get _initialIndex { if (widget.initialScrollIndex != null) return widget.initialScrollIndex; @@ -321,40 +352,40 @@ class _MessageListViewState extends State { bool _inBetweenList = false; - final MessageListController _messageListController = MessageListController(); + late final _defaultController = MessageListController(); + MessageListController get _messageListController => + widget.messageListController ?? _defaultController; @override - Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - return MessageListCore( - messageFilter: widget.messageFilter, - loadingBuilder: widget.loadingBuilder ?? - (context) => const Center( - child: CircularProgressIndicator(), - ), - emptyBuilder: widget.emptyBuilder ?? - (context) => Center( - child: Text( - 'No chats here yet...', - style: chatThemeData.textTheme.footnote.copyWith( - color: chatThemeData.colorTheme.black.withOpacity(.5)), + Widget build(BuildContext context) => MessageListCore( + messageFilter: widget.messageFilter, + loadingBuilder: widget.loadingBuilder ?? + (context) => const Center( + child: CircularProgressIndicator(), ), - ), - messageListBuilder: - widget.messageListBuilder ?? (context, list) => _buildListView(list), - messageListController: _messageListController, - parentMessage: widget.parentMessage, - showScrollToBottom: widget.showScrollToBottom, - errorWidgetBuilder: widget.errorWidgetBuilder ?? - (BuildContext context, Object error) => Center( - child: Text( - 'Something went wrong', - style: chatThemeData.textTheme.footnote.copyWith( - color: chatThemeData.colorTheme.black.withOpacity(.5)), + emptyBuilder: widget.emptyBuilder ?? + (context) => Center( + child: Text( + 'No chats here yet...', + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + .withOpacity(.5)), + ), ), - ), - ); - } + messageListBuilder: widget.messageListBuilder ?? + (context, list) => _buildListView(list), + messageListController: _messageListController, + parentMessage: widget.parentMessage, + errorWidgetBuilder: widget.errorWidgetBuilder ?? + (BuildContext context, Object error) => Center( + child: Text( + 'Something went wrong', + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + .withOpacity(.5)), + ), + ), + ); Widget _buildListView(List data) { messages = data; @@ -379,6 +410,12 @@ class _MessageListViewState extends State { _messageListLength = newMessagesListLength; + final itemCount = messages.length + // total messages + 2 + // top + bottom loading indicator + 2 + // header + footer + 1 // parent message + ; + return Stack( alignment: Alignment.center, children: [ @@ -400,8 +437,7 @@ class _MessageListViewState extends State { } return InfoTile( - // ignore: avoid_bool_literals_in_conditional_expressions - showMessage: widget.showConnectionStateTile ? showStatus : false, + showMessage: widget.showConnectionStateTile && showStatus, tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, message: statusString, @@ -440,33 +476,52 @@ class _MessageListViewState extends State { physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: true, - itemCount: - messages.length + 2 + (_isThreadConversation ? 1 : 0), + addAutomaticKeepAlives: false, + itemCount: itemCount, + + // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) + // eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)| + // ParentMessage -> 7 (count-1) + // Separator(ThreadSeparator) -> 6 (count-2) + // Header -> 6 (count-2) + // Separator(Header -> 8??T -> 0||52) -> 5 (count-3) + // TopLoader -> 5 (count-3) + // Separator(0) -> 4 (count-4) + // Message -> 4 (count-4) + // Separator(2||8) -> 3 (count-5) + // Message -> 3 (count-5) + // Separator(2||8) -> 2 (count-6) + // Message -> 2 (count-6) + // Separator(0) -> 1 (count-7) + // BottomLoader -> 1 (count-7) + // Separator(Footer -> 8??30) -> 0 (count-8) + // Footer -> 0 (count-8) + separatorBuilder: (context, i) { - if (i == messages.length) return const Offstage(); - if (i == 0) return const SizedBox(height: 30); - if (i == messages.length + 1) { - final replyCount = widget.parentMessage!.replyCount; - final chatThemeData = StreamChatTheme.of(context); - return Container( - decoration: BoxDecoration( - gradient: chatThemeData.colorTheme.bgGradient, - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - // ignore: lines_longer_than_80_chars - '$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}', - textAlign: TextAlign.center, - style: chatThemeData - .channelTheme.channelHeaderTheme.subtitle, - ), - ), - ); + if (i == itemCount - 2) { + if (widget.parentMessage == null) { + return const Offstage(); + } + return _buildThreadSeparator(); + } + if (i == itemCount - 3) { + if (widget.headerBuilder == null) { + if (_isThreadConversation) return const Offstage(); + return const SizedBox(height: 52); + } + return const SizedBox(height: 8); + } + if (i == 0) { + if (widget.footerBuilder == null) { + return const SizedBox(height: 30); + } + return const SizedBox(height: 8); } - final message = messages[i]; - final nextMessage = messages[i - 1]; + if (i == 1 || i == itemCount - 4) return const Offstage(); + + final message = messages[i - 1]; + final nextMessage = messages[i - 2]; if (!Jiffy(message.createdAt.toLocal()).isSame( nextMessage.createdAt.toLocal(), Units.DAY, @@ -502,63 +557,50 @@ class _MessageListViewState extends State { return const SizedBox(height: 2); }, itemBuilder: (context, i) { - if (i == messages.length + 2) { - if (widget.parentMessageBuilder != null) { - return widget.parentMessageBuilder!( - context, - widget.parentMessage, - ); - } else { - return buildParentMessage(widget.parentMessage!); - } + if (i == itemCount - 1) { + if (widget.parentMessage == null) return const Offstage(); + return buildParentMessage(widget.parentMessage!); } - if (i == messages.length + 1) { + + if (i == itemCount - 2) { + return widget.headerBuilder?.call(context) ?? + const Offstage(); + } + + if (i == itemCount - 3) { return _buildLoadingIndicator( streamChannel!, QueryDirection.top, ); } - if (i == 0) { + + if (i == 1) { return _buildLoadingIndicator( streamChannel!, QueryDirection.bottom, ); } - final message = messages[i - 1]; + if (i == 0) { + return widget.footerBuilder?.call(context) ?? + const Offstage(); + } + + const bottomMessageIndex = 2; // 1 -> loader // 0 -> footer + + final message = messages[i - 2]; Widget messageWidget; - if (i == 1) { + if (i == bottomMessageIndex) { messageWidget = _buildBottomMessage( context, message, messages, streamChannel!, - ); - } else if (i == messages.length - 1) { - messageWidget = _buildTopMessage( - context, - message, - messages, - streamChannel, + i - 2, ); } else { - if (widget.messageBuilder != null) { - messageWidget = Builder( - key: ValueKey('MESSAGE-${message.id}'), - builder: (context) => widget.messageBuilder!( - context, - MessageDetails( - context, - message, - messages, - i, - ), - messages), - ); - } else { - messageWidget = buildMessage(message, messages, i); - } + messageWidget = buildMessage(message, messages, i - 2); } return messageWidget; }, @@ -568,60 +610,82 @@ class _MessageListViewState extends State { }, ), if (widget.showScrollToBottom) _buildScrollToBottom(), - Positioned( - top: 20, - child: ValueListenableBuilder>( - valueListenable: _itemPositionListener.itemPositions, - builder: (context, values, _) { - final items = _itemPositionListener.itemPositions.value; - if (items.isEmpty || messages.isEmpty) { - return const SizedBox(); - } - - var index = _getTopElement(values)?.index; - - if (index == null || index > messages.length) { - return const SizedBox(); - } - - if (index == messages.length) { - index = max(index - 1, 0); - } - - return widget.dateDividerBuilder != null - ? widget.dateDividerBuilder!( - messages[index].createdAt.toLocal(), - ) - : DateDivider( - dateTime: messages[index].createdAt.toLocal(), - ); - }, - ), - ), + if (widget.showFloatingDateDivider) + _buildFloatingDateDivider(itemCount), ], ); } + Widget _buildThreadSeparator() { + if (widget.threadSeparatorBuilder != null) { + return widget.threadSeparatorBuilder!.call(context); + } + + final replyCount = widget.parentMessage!.replyCount; + return DecoratedBox( + decoration: BoxDecoration( + gradient: _streamTheme.colorTheme.bgGradient, + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + '$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}', + textAlign: TextAlign.center, + style: _streamTheme.channelTheme.channelHeaderTheme.subtitle, + ), + ), + ); + } + + Positioned _buildFloatingDateDivider(int itemCount) => Positioned( + top: 20, + child: BetterStreamBuilder>( + initialData: _itemPositionListener.itemPositions.value, + stream: _itemPositionStream, + comparator: (a, b) { + if (a == null || b == null) { + return false; + } + final aTop = _getTopElementIndex(a); + final bTop = _getTopElementIndex(b); + return aTop == bTop; + }, + builder: (context, values) { + if (values.isEmpty || messages.isEmpty) { + return const Offstage(); + } + + final index = _getTopElementIndex(values); + + if (index == null || index <= 2 || index >= itemCount - 3) { + return const Offstage(); + } + + final message = messages[index - 2]; + return widget.dateDividerBuilder != null + ? widget.dateDividerBuilder!(message.createdAt.toLocal()) + : DateDivider(dateTime: message.createdAt.toLocal()); + }, + ), + ); + Future _paginateData( StreamChannelState? channel, QueryDirection direction) => _messageListController.paginateData!(direction: direction); - ItemPosition? _getTopElement(Iterable values) { - final inView = - values.where((ItemPosition position) => position.itemLeadingEdge < 0.9); - - if (inView.isEmpty) { - return null; - } - - return inView.reduce((ItemPosition max, ItemPosition position) => - position.itemLeadingEdge > max.itemLeadingEdge ? position : max); + int? _getTopElementIndex(Iterable values) { + final inView = values.where((position) => position.itemLeadingEdge < 1); + if (inView.isEmpty) return null; + return inView + .reduce((max, position) => + position.itemLeadingEdge > max.itemLeadingEdge ? position : max) + .index; } Widget _buildScrollToBottom() => StreamBuilder>( stream: Rx.combineLatest2( - streamChannel!.channel.state!.isUpToDateStream, - streamChannel!.channel.state!.unreadCountStream, + streamChannel!.channel.state!.isUpToDateStream.distinct(), + streamChannel!.channel.state!.unreadCountStream.distinct(), (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), ), builder: (_, snapshot) { @@ -639,7 +703,6 @@ class _MessageListViewState extends State { final showUnreadCount = unreadCount > 0 && streamChannel!.channel.state!.members.any((e) => e.userId == streamChannel!.channel.client.state.user!.id); - final chatThemeData = StreamChatTheme.of(context); return Positioned( bottom: 8, right: 8, @@ -649,7 +712,7 @@ class _MessageListViewState extends State { clipBehavior: Clip.none, children: [ FloatingActionButton( - backgroundColor: chatThemeData.colorTheme.white, + backgroundColor: _streamTheme.colorTheme.barsBg, onPressed: () { if (unreadCount > 0) { streamChannel!.channel.markRead(); @@ -668,7 +731,7 @@ class _MessageListViewState extends State { } }, child: StreamSvgIcon.down( - color: chatThemeData.colorTheme.black, + color: _streamTheme.colorTheme.textHighEmphasis, ), ), if (showUnreadCount) @@ -699,96 +762,22 @@ class _MessageListViewState extends State { Widget _buildLoadingIndicator( StreamChannelState streamChannel, QueryDirection direction, - ) { - final stream = direction == QueryDirection.top - ? streamChannel.queryTopMessages - : streamChannel.queryBottomMessages; - return StreamBuilder( - key: const Key('LOADING-INDICATOR'), - stream: stream, - initialData: false, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Container( - color: StreamChatTheme.of(context) - .colorTheme - .accentRed - .withOpacity(.2), - child: const Center( - child: Text('Error loading messages'), - ), - ); - } - if (!snapshot.data!) { - if (!_isThreadConversation && direction == QueryDirection.top) { - return const SizedBox( - height: 52, - width: double.infinity, - ); - } - return const Offstage(); - } - return const Center( - child: Padding( - padding: EdgeInsets.all(8), - child: CircularProgressIndicator(), - ), - ); - }, - ); - } - - Widget _buildTopMessage( - BuildContext context, - Message message, - List messages, - StreamChannelState? streamChannel, - ) { - Widget messageWidget; - if (widget.messageBuilder != null) { - messageWidget = Builder( - key: const ValueKey('TOP-MESSAGE'), - builder: (_) => widget.messageBuilder!( - context, - MessageDetails( - context, - message, - messages, - messages.length - 1, - ), - messages, - ), + ) => + _LoadingIndicator( + direction: direction, + streamTheme: _streamTheme, + streamChannel: streamChannel, + isThreadConversation: _isThreadConversation, ); - } else { - messageWidget = buildMessage(message, messages, messages.length - 1); - } - return messageWidget; - } Widget _buildBottomMessage( BuildContext context, Message message, List messages, StreamChannelState streamChannel, + int index, ) { - Widget messageWidget; - if (widget.messageBuilder != null) { - messageWidget = Builder( - key: ValueKey('BOTTOM-MESSAGE-${message.id}'), - builder: (_) => widget.messageBuilder!( - context, - MessageDetails( - context, - message, - messages, - 0, - ), - messages, - ), - ); - } else { - messageWidget = buildMessage(message, messages, 0); - } + final messageWidget = buildMessage(message, messages, index); return VisibilityDetector( key: ValueKey('BOTTOM-MESSAGE-${message.id}'), @@ -798,12 +787,14 @@ class _MessageListViewState extends State { final channel = streamChannel.channel; if (_upToDate && channel.config?.readEvents == true && - channel.state!.unreadCount! > 0) { + channel.state!.unreadCount > 0) { streamChannel.channel.markRead(); } } if (mounted) { - setState(() => _showScrollToBottom = !isVisible); + if (_showScrollToBottom == isVisible) { + setState(() => _showScrollToBottom = !isVisible); + } } }, child: messageWidget, @@ -818,10 +809,9 @@ class _MessageListViewState extends State { final currentUser = StreamChat.of(context).user; final members = StreamChannel.of(context).channel.state?.members ?? []; final currentUserMember = - members.firstWhere((e) => e.user!.id == currentUser!.id); + members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); - final chatThemeData = StreamChatTheme.of(context); - return MessageWidget( + final defaultMessageWidget = MessageWidget( showReplyMessage: false, showResendMessage: false, showThreadReplyMessage: false, @@ -849,8 +839,8 @@ class _MessageListViewState extends State { borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null, showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, messageTheme: isMyMessage - ? chatThemeData.ownMessageTheme - : chatThemeData.otherMessageTheme, + ? _streamTheme.ownMessageTheme + : _streamTheme.otherMessageTheme, onShowMessage: widget.onShowMessage, onReturnAction: (action) { switch (action) { @@ -872,8 +862,19 @@ class _MessageListViewState extends State { textBuilder: widget.textBuilder, usernameBuilder: widget.usernameBuilder, onLinkTap: widget.onLinkTap, - showPinButton: widget.pinPermissions.contains(currentUserMember.role), + showPinButton: currentUserMember != null && + widget.pinPermissions.contains(currentUserMember.role), ); + + if (widget.parentMessageBuilder != null) { + return widget.parentMessageBuilder!.call( + context, + widget.parentMessage, + defaultMessageWidget, + ); + } + + return defaultMessageWidget; } Widget buildMessage( @@ -898,7 +899,7 @@ class _MessageListViewState extends State { final userId = StreamChat.of(context).user!.id; final isMyMessage = message.user!.id == userId; - final nextMessage = index - 2 >= 0 ? messages[index - 2] : null; + final nextMessage = index - 3 >= 0 ? messages[index - 3] : null; final isNextUserSame = nextMessage != null && message.user!.id == nextMessage.user!.id; @@ -964,8 +965,7 @@ class _MessageListViewState extends State { final currentUserMember = members.firstWhere((e) => e.user!.id == currentUser!.id); - final chatThemeData = StreamChatTheme.of(context); - Widget child = MessageWidget( + Widget messageWidget = MessageWidget( key: ValueKey('MESSAGE-${message.id}'), message: message, reverse: isMyMessage, @@ -1051,8 +1051,8 @@ class _MessageListViewState extends State { horizontal: isOnlyEmoji ? 0 : 16.0, ), messageTheme: isMyMessage - ? chatThemeData.ownMessageTheme - : chatThemeData.otherMessageTheme, + ? _streamTheme.ownMessageTheme + : _streamTheme.otherMessageTheme, readList: readList, allRead: allRead, onShowMessage: widget.onShowMessage, @@ -1080,6 +1080,21 @@ class _MessageListViewState extends State { showPinButton: widget.pinPermissions.contains(currentUserMember.role), ); + if (widget.messageBuilder != null) { + messageWidget = widget.messageBuilder!( + context, + MessageDetails( + userId, + message, + messages, + index, + ), + messages, + messageWidget as MessageWidget, + ); + } + + var child = messageWidget; if (!message.isDeleted && !message.isSystem && !message.isEphemeral && @@ -1093,7 +1108,7 @@ class _MessageListViewState extends State { widget.onMessageSwiped?.call(message); }, backgroundIcon: StreamSvgIcon.reply( - color: chatThemeData.colorTheme.accentBlue, + color: _streamTheme.colorTheme.accentPrimary, ), child: child, ), @@ -1103,13 +1118,13 @@ class _MessageListViewState extends State { if (!initialMessageHighlightComplete && widget.highlightInitialMessage && _isInitialMessage(message.id)) { - final colorTheme = chatThemeData.colorTheme; + final colorTheme = _streamTheme.colorTheme; final highlightColor = widget.messageHighlightColor ?? colorTheme.highlight; child = TweenAnimationBuilder( tween: ColorTween( begin: highlightColor, - end: colorTheme.white.withOpacity(0), + end: colorTheme.barsBg.withOpacity(0), ), duration: const Duration(seconds: 3), onEnd: () => initialMessageHighlightComplete = true, @@ -1133,6 +1148,8 @@ class _MessageListViewState extends State { _scrollController = widget.scrollController ?? ItemScrollController(); _itemPositionListener = widget.itemPositionListener ?? ItemPositionsListener.create(); + _itemPositionStream = + _valueListenableToStreamAdapter(_itemPositionListener.itemPositions); _getOnThreadTap(); super.initState(); @@ -1141,6 +1158,7 @@ class _MessageListViewState extends State { @override void didChangeDependencies() { final newStreamChannel = StreamChannel.of(context); + _streamTheme = StreamChatTheme.of(context); if (newStreamChannel != streamChannel) { streamChannel = newStreamChannel; @@ -1186,14 +1204,14 @@ class _MessageListViewState extends State { Navigator.push( context, MaterialPageRoute( - builder: (_) => StreamBuilder( + builder: (_) => BetterStreamBuilder( stream: streamChannel!.channel.state!.messagesStream.map( (messages) => messages!.firstWhere((m) => m.id == message.id)), initialData: message, - builder: (_, snapshot) => StreamChannel( + builder: (_, data) => StreamChannel( channel: streamChannel!.channel, - child: widget.threadBuilder!(context, snapshot.data), + child: widget.threadBuilder!(context, data), ), ), ), @@ -1211,3 +1229,71 @@ class _MessageListViewState extends State { super.dispose(); } } + +class _LoadingIndicator extends StatelessWidget { + const _LoadingIndicator({ + Key? key, + required this.streamTheme, + required this.isThreadConversation, + required this.direction, + required this.streamChannel, + }) : super(key: key); + + final StreamChatThemeData streamTheme; + final bool isThreadConversation; + final QueryDirection direction; + final StreamChannelState streamChannel; + + @override + Widget build(BuildContext context) { + final stream = direction == QueryDirection.top + ? streamChannel.queryTopMessages + : streamChannel.queryBottomMessages; + return BetterStreamBuilder( + key: Key('LOADING-INDICATOR $direction'), + stream: stream, + initialData: false, + errorBuilder: (context, error) => Container( + color: streamTheme.colorTheme.accentError.withOpacity(.2), + child: const Center( + child: Text('Error loading messages'), + ), + ), + builder: (context, data) { + if (!data) return const Offstage(); + return const Center( + child: Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ), + ); + }, + ); + } +} + +Stream _valueListenableToStreamAdapter(ValueListenable listenable) { + // ignore: close_sinks + late StreamController _controller; + + void listener() { + _controller.add(listenable.value); + } + + void start() { + listenable.addListener(listener); + } + + void end() { + listenable.removeListener(listener); + } + + _controller = StreamController( + onListen: start, + onPause: end, + onResume: start, + onCancel: end, + ); + + return _controller.stream; +} diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index 9d78e3d9..3d9a907e 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -1,7 +1,6 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/src/reaction_picker.dart'; import 'package:stream_chat_flutter/src/stream_chat.dart'; @@ -15,17 +14,16 @@ class MessageReactionsModal extends StatelessWidget { const MessageReactionsModal({ Key? key, required this.message, + required this.messageWidget, required this.messageTheme, this.showReactions = true, - this.messageShape, - this.attachmentShape, this.reverse = false, - this.showUserAvatar = DisplayWidget.show, this.onUserAvatarTap, - this.attachmentBorderRadiusGeometry, - this.textBuilder, }) : super(key: key); + /// Widget that shows the message + final Widget messageWidget; + /// Message to display reactions of final Message message; @@ -38,24 +36,9 @@ class MessageReactionsModal extends StatelessWidget { /// Flag to show reactions on message final bool showReactions; - /// Enum to change user avatar config - final DisplayWidget showUserAvatar; - - /// [ShapeBorder] to apply to message - final ShapeBorder? messageShape; - - /// [ShapeBorder] to apply to attachment - final ShapeBorder? attachmentShape; - /// Callback when user avatar is tapped final void Function(User)? onUserAvatarTap; - /// [BorderRadius] to apply to attachments - final BorderRadius? attachmentBorderRadiusGeometry; - - /// Customize the MessageWidget textBuilder - final Widget Function(BuildContext context, Message message)? textBuilder; - @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; @@ -78,115 +61,88 @@ class MessageReactionsModal extends StatelessWidget { ? 1 : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutBack, - builder: (context, val, snapshot) { - final hasFileAttachment = - message.attachments.any((it) => it.type == 'file') == true; - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.maybePop(context), - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: Container( - color: StreamChatTheme.of(context).colorTheme.overlay, + final numberOfReactions = StreamChatTheme.of(context).reactionIcons.length; + final shiftFactor = + numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0; + + final child = Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showReactions && + (message.status == MessageSendingStatus.sent)) + Align( + alignment: Alignment( + user!.id == message.user!.id + ? (divFactor >= 1.0 + ? -0.2 - shiftFactor + : (1.2 - divFactor)) + : (divFactor >= 1.0 + ? 0.2 + shiftFactor + : -(1.2 - divFactor)), + 0), + child: ReactionPicker( + message: message, ), ), + const SizedBox(height: 8), + IgnorePointer( + child: messageWidget, ), - Transform.scale( - scale: val, - child: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showReactions && - (message.status == MessageSendingStatus.sent)) - Align( - alignment: Alignment( - user!.id == message.user!.id - ? (divFactor >= 1.0 - ? -0.2 - : (1.2 - divFactor)) - : (divFactor >= 1.0 - ? 0.2 - : -(1.2 - divFactor)), - 0), - child: ReactionPicker( - message: message, - ), - ), - const SizedBox(height: 8), - IgnorePointer( - child: MessageWidget( - key: const Key('MessageWidget'), - reverse: reverse, - message: message.copyWith( - text: message.text!.length > 200 - ? '${message.text!.substring(0, 200)}...' - : message.text, - ), - messageTheme: messageTheme, - showReactions: false, - showUsername: false, - showUserAvatar: showUserAvatar, - showTimestamp: false, - translateUserAvatar: false, - showSendingIndicator: false, - shape: messageShape, - attachmentShape: attachmentShape, - padding: const EdgeInsets.all(0), - attachmentBorderRadiusGeometry: - attachmentBorderRadiusGeometry - ?.mirrorBorderIfReversed( - reverse: !reverse), - attachmentPadding: EdgeInsets.all( - hasFileAttachment ? 4 : 2, - ), - textPadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: - message.text!.isOnlyEmoji ? 0 : 16.0, - ), - showReactionPickerIndicator: showReactions && - (message.status == MessageSendingStatus.sent), - textBuilder: textBuilder, - showPinHighlight: false, - ), - ), - if (message.latestReactions?.isNotEmpty == true) ...[ - const SizedBox(height: 8), - _buildReactionCard(context), - ] - ], - ), - ), - ), + if (message.latestReactions?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + _buildReactionCard( + context, + user, ), - ), + ] ], ), - ); - }, + ), + ), + ); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.overlay, + ), + ), + ), + ), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutBack, + builder: (context, val, widget) => Transform.scale( + scale: val, + child: widget, + ), + child: child, + ), + ], + ), ); } - Widget _buildReactionCard(BuildContext context) { - final currentUser = StreamChat.of(context).user; + Widget _buildReactionCard(BuildContext context, User? user) { final chatThemeData = StreamChatTheme.of(context); return Card( - color: chatThemeData.colorTheme.white, + color: chatThemeData.colorTheme.barsBg, clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -210,7 +166,7 @@ class MessageReactionsModal extends StatelessWidget { children: message.latestReactions! .map((e) => _buildReaction( e, - currentUser!, + user!, context, )) .toList(), @@ -268,7 +224,7 @@ class MessageReactionsModal extends StatelessWidget { messageTheme.reactionsBorderColor ?? Colors.transparent, backgroundColor: messageTheme.reactionsBackgroundColor ?? Colors.transparent, - maskColor: chatThemeData.colorTheme.white, + maskColor: chatThemeData.colorTheme.barsBg, tailCirclesSpacing: 1, highlightOwnReactions: false, ), diff --git a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart index 8b8a8dec..f364d0f2 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart @@ -68,6 +68,7 @@ class MessageSearchListView extends StatefulWidget { this.errorBuilder, this.loadingBuilder, this.childBuilder, + this.messageSearchListController, }) : super(key: key); /// Message String to search on @@ -127,13 +128,20 @@ class MessageSearchListView extends StatefulWidget { /// The builder that will be used in case of loading final WidgetBuilder? loadingBuilder; + /// A [MessageSearchListController] allows reloading and pagination. + /// Use [MessageSearchListController.loadData] and + /// [MessageSearchListController.paginateData] respectively for reloading and + /// pagination. + final MessageSearchListController? messageSearchListController; + @override _MessageSearchListViewState createState() => _MessageSearchListViewState(); } class _MessageSearchListViewState extends State { - final MessageSearchListController _messageSearchListController = - MessageSearchListController(); + late final _defaultController = MessageSearchListController(); + MessageSearchListController get _messageSearchListController => + widget.messageSearchListController ?? _defaultController; @override Widget build(BuildContext context) => MessageSearchListCore( @@ -191,7 +199,7 @@ class _MessageSearchListViewState extends State { Widget _separatorBuilder(BuildContext context, int index) => Container( height: 1, - color: StreamChatTheme.of(context).colorTheme.greyWhisper, + color: StreamChatTheme.of(context).colorTheme.borders, ); Widget _listItemBuilder( @@ -216,7 +224,7 @@ class _MessageSearchListViewState extends State { return Container( color: StreamChatTheme.of(context) .colorTheme - .accentRed + .accentError .withOpacity(.2), child: const Padding( padding: EdgeInsets.symmetric(vertical: 16), @@ -286,7 +294,7 @@ class _MessageSearchListViewState extends State { child: Text( '${items.length} results', style: TextStyle( - color: chatThemeData.colorTheme.grey, + color: chatThemeData.colorTheme.textLowEmphasis, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_text.dart b/packages/stream_chat_flutter/lib/src/message_text.dart index e5abede0..6d006085 100644 --- a/packages/stream_chat_flutter/lib/src/message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_text.dart @@ -29,7 +29,7 @@ class MessageText extends StatelessWidget { @override Widget build(BuildContext context) { - final text = _replaceMentions(message.text ?? '').replaceAll('\n', '\\\n'); + final text = _replaceMentions(message.text ?? '').replaceAll('\n', '\n\n'); final themeData = Theme.of(context); return MarkdownBody( diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index f1b5428d..c9dfd9d4 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -89,6 +89,7 @@ class MessageWidget extends StatefulWidget { this.onLinkTap, this.onMessageActions, this.onShowMessage, + this.userAvatarBuilder, this.editMessageInputBuilder, this.textBuilder, this.onReturnAction, @@ -221,7 +222,7 @@ class MessageWidget extends StatefulWidget { final border = RoundedRectangleBorder( side: attachmentBorderSide ?? BorderSide( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, + color: StreamChatTheme.of(context).colorTheme.borders, ), borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); @@ -274,6 +275,9 @@ class MessageWidget extends StatefulWidget { /// Function called on long press final void Function(BuildContext, Message)? onMessageActions; + /// Widget builder for building user avatar + final Widget Function(BuildContext, User)? userAvatarBuilder; + /// The message final Message message; @@ -397,6 +401,119 @@ class MessageWidget extends StatefulWidget { /// Customize onTap on attachment final void Function(Message message, Attachment attachment)? onAttachmentTap; + /// Creates a copy of [MessageWidget] with specified attributes overridden. + MessageWidget copyWith({ + Key? key, + void Function(User)? onMentionTap, + void Function(Message)? onThreadTap, + void Function(Message)? onReplyTap, + Widget Function(BuildContext, Message)? editMessageInputBuilder, + Widget Function(BuildContext, Message)? textBuilder, + Widget Function(BuildContext, Message)? usernameBuilder, + void Function(BuildContext, Message)? onMessageActions, + Message? message, + MessageTheme? messageTheme, + bool? reverse, + ShapeBorder? shape, + ShapeBorder? attachmentShape, + BorderSide? borderSide, + BorderSide? attachmentBorderSide, + BorderRadiusGeometry? borderRadiusGeometry, + BorderRadiusGeometry? attachmentBorderRadiusGeometry, + EdgeInsetsGeometry? padding, + EdgeInsets? textPadding, + EdgeInsetsGeometry? attachmentPadding, + DisplayWidget? showUserAvatar, + bool? showSendingIndicator, + bool? showReactions, + bool? allRead, + bool? showThreadReplyIndicator, + bool? showInChannelIndicator, + void Function(User)? onUserAvatarTap, + void Function(String)? onLinkTap, + bool? showReactionPickerIndicator, + List? readList, + ShowMessageCallback? onShowMessage, + ValueChanged? onReturnAction, + bool? showUsername, + bool? showTimestamp, + bool? showReplyMessage, + bool? showThreadReplyMessage, + bool? showEditMessage, + bool? showCopyMessage, + bool? showDeleteMessage, + bool? showResendMessage, + bool? showFlagButton, + bool? showPinButton, + bool? showPinHighlight, + Map? customAttachmentBuilders, + bool? translateUserAvatar, + OnQuotedMessageTap? onQuotedMessageTap, + void Function(Message)? onMessageTap, + List? customActions, + void Function(Message message, Attachment attachment)? onAttachmentTap, + Widget Function(BuildContext, User)? userAvatarBuilder, + }) => + MessageWidget( + key: key ?? this.key, + onMentionTap: onMentionTap ?? this.onMentionTap, + onThreadTap: onThreadTap ?? this.onThreadTap, + onReplyTap: onReplyTap ?? this.onReplyTap, + editMessageInputBuilder: + editMessageInputBuilder ?? this.editMessageInputBuilder, + textBuilder: textBuilder ?? this.textBuilder, + usernameBuilder: usernameBuilder ?? this.usernameBuilder, + onMessageActions: onMessageActions ?? this.onMessageActions, + message: message ?? this.message, + messageTheme: messageTheme ?? this.messageTheme, + reverse: reverse ?? this.reverse, + shape: shape ?? this.shape, + attachmentShape: attachmentShape ?? this.attachmentShape, + borderSide: borderSide ?? this.borderSide, + attachmentBorderSide: attachmentBorderSide ?? this.attachmentBorderSide, + borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry, + attachmentBorderRadiusGeometry: attachmentBorderRadiusGeometry ?? + this.attachmentBorderRadiusGeometry, + padding: padding ?? this.padding, + textPadding: textPadding ?? this.textPadding, + attachmentPadding: attachmentPadding ?? this.attachmentPadding, + showUserAvatar: showUserAvatar ?? this.showUserAvatar, + showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, + showReactions: showReactions ?? this.showReactions, + allRead: allRead ?? this.allRead, + showThreadReplyIndicator: + showThreadReplyIndicator ?? this.showThreadReplyIndicator, + showInChannelIndicator: + showInChannelIndicator ?? this.showInChannelIndicator, + onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, + onLinkTap: onLinkTap ?? this.onLinkTap, + showReactionPickerIndicator: + showReactionPickerIndicator ?? this.showReactionPickerIndicator, + readList: readList ?? this.readList, + onShowMessage: onShowMessage ?? this.onShowMessage, + onReturnAction: onReturnAction ?? this.onReturnAction, + showUsername: showUsername ?? this.showUsername, + showTimestamp: showTimestamp ?? this.showTimestamp, + showReplyMessage: showReplyMessage ?? this.showReplyMessage, + showThreadReplyMessage: + showThreadReplyMessage ?? this.showThreadReplyMessage, + showEditMessage: showEditMessage ?? this.showEditMessage, + showCopyMessage: showCopyMessage ?? this.showCopyMessage, + showDeleteMessage: showDeleteMessage ?? this.showDeleteMessage, + showResendMessage: showResendMessage ?? this.showResendMessage, + showFlagButton: showFlagButton ?? this.showFlagButton, + showPinButton: showPinButton ?? this.showPinButton, + showPinHighlight: showPinHighlight ?? this.showPinHighlight, + customAttachmentBuilders: + customAttachmentBuilders ?? attachmentBuilders, + translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar, + onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, + onMessageTap: onMessageTap ?? this.onMessageTap, + customActions: customActions ?? this.customActions, + onAttachmentTap: onAttachmentTap ?? this.onAttachmentTap, + userAvatarBuilder: userAvatarBuilder ?? this.userAvatarBuilder, + ); + @override _MessageWidgetState createState() => _MessageWidgetState(); } @@ -433,11 +550,11 @@ class _MessageWidgetState extends State widget.message.attachments.any((element) => element.type == 'giphy') == true; - bool get hasNonUrlAttachments => - widget.message.attachments - .where((it) => it.ogScrapeUrl == null) - .isNotEmpty == - true; + bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true; + + bool get hasNonUrlAttachments => widget.message.attachments + .where((it) => it.ogScrapeUrl == null) + .isNotEmpty; bool get hasUrlAttachments => widget.message.attachments.any((it) => it.ogScrapeUrl != null) == true; @@ -453,12 +570,15 @@ class _MessageWidgetState extends State @override bool get wantKeepAlive => widget.message.attachments.isNotEmpty == true; + late StreamChatThemeData _streamChatTheme; + late StreamChatState _streamChat; + @override Widget build(BuildContext context) { super.build(context); final avatarWidth = widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; - final leftPadding = + final bottomRowPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; return Material( @@ -466,7 +586,7 @@ class _MessageWidgetState extends State ? MaterialType.card : MaterialType.transparency, color: widget.message.pinned && widget.showPinHighlight - ? StreamChatTheme.of(context).colorTheme.highlight + ? _streamChatTheme.colorTheme.highlight : null, child: Portal( child: InkWell( @@ -513,7 +633,8 @@ class _MessageWidgetState extends State crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - if (widget.showUserAvatar == + if (!widget.reverse && + widget.showUserAvatar == DisplayWidget.show && widget.message.user != null) ...[ _buildUserAvatar(), @@ -527,7 +648,8 @@ class _MessageWidgetState extends State transform: Matrix4.translationValues( widget.reverse ? 12 : -12, 0, 0), constraints: const BoxConstraints( - maxWidth: 22 * 6.0), + maxWidth: 22 * 6.0, + ), child: _buildReactionIndicator(context), ), portalAnchor: @@ -572,7 +694,7 @@ class _MessageWidgetState extends State ), ) : Card( - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, elevation: 0, margin: EdgeInsets.symmetric( horizontal: (isFailedState @@ -626,9 +748,8 @@ class _MessageWidgetState extends State top: -8, child: CustomPaint( painter: ReactionBubblePainter( - StreamChatTheme.of(context) - .colorTheme - .white, + _streamChatTheme + .colorTheme.barsBg, Colors.transparent, Colors.transparent, tailCirclesSpace: 1, @@ -639,6 +760,13 @@ class _MessageWidgetState extends State ), ), ), + if (widget.reverse && + widget.showUserAvatar == + DisplayWidget.show && + widget.message.user != null) ...[ + _buildUserAvatar(), + const SizedBox(width: 4), + ] ], ), if (showBottomRow) @@ -649,7 +777,8 @@ class _MessageWidgetState extends State if (showBottomRow) Padding( padding: EdgeInsets.only( - left: leftPadding, + left: !widget.reverse ? bottomRowPadding : 0, + right: widget.reverse ? bottomRowPadding : 0, bottom: isPinned && widget.showPinHighlight ? 6.0 : 0.0, ), @@ -673,14 +802,20 @@ class _MessageWidgetState extends State ); } + @override + void didChangeDependencies() { + _streamChatTheme = StreamChatTheme.of(context); + _streamChat = StreamChat.of(context); + super.didChangeDependencies(); + } + Widget _buildQuotedMessage() { - final isMyMessage = - widget.message.user?.id == StreamChat.of(context).user?.id; + final isMyMessage = widget.message.user?.id == _streamChat.user?.id; final onTap = widget.message.quotedMessage?.isDeleted != true && widget.onQuotedMessageTap != null ? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId) : null; - final chatThemeData = StreamChatTheme.of(context); + final chatThemeData = _streamChatTheme; return QuotedMessageWidget( onTap: onTap, message: widget.message.quotedMessage!, @@ -695,19 +830,19 @@ class _MessageWidgetState extends State Widget get _bottomRow { if (isDeleted) { - final chatThemeData = StreamChatTheme.of(context); + final chatThemeData = _streamChatTheme; return Row( mainAxisSize: MainAxisSize.min, children: [ StreamSvgIcon.eye( - color: chatThemeData.colorTheme.grey, + color: chatThemeData.colorTheme.textLowEmphasis, size: 16, ), const SizedBox(width: 8), Text( 'Only visible to you', style: chatThemeData.textTheme.footnote - .copyWith(color: chatThemeData.colorTheme.grey), + .copyWith(color: chatThemeData.colorTheme.textLowEmphasis), ), ], ); @@ -849,36 +984,16 @@ class _MessageWidgetState extends State ); } - Widget _buildThreadParticipantsIndicator(Iterable threadParticipants) { - var padding = 0.0; - return Stack( - children: threadParticipants.map((user) { - padding += 8.0; - return Positioned( - right: padding - 8, - bottom: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: StreamChatTheme.of(context).colorTheme.white, - ), - padding: const EdgeInsets.all(1), - child: UserAvatar( - user: user, - constraints: BoxConstraints.loose(const Size.fromRadius(7)), - showOnlineStatus: false, - ), - ), - ); - }).toList(), - ); - } + Widget _buildThreadParticipantsIndicator(Iterable threadParticipants) => + _ThreadParticipants( + streamChatTheme: _streamChatTheme, + threadParticipants: threadParticipants, + ); Widget _buildReactionIndicator( BuildContext context, ) { - final ownId = StreamChat.of(context).user!.id; + final ownId = _streamChat.user!.id; final reactionsMap = {}; widget.message.latestReactions?.forEach((element) { if (!reactionsMap.containsKey(element.type) || @@ -917,24 +1032,36 @@ class _MessageWidgetState extends State final channel = StreamChannel.of(context).channel; showDialog( + useRootNavigator: false, context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, + barrierColor: _streamChatTheme.colorTheme.overlay, builder: (context) => StreamChannel( channel: channel, child: MessageActionsModal( - textBuilder: widget.textBuilder, + messageWidget: widget.copyWith( + key: const Key('MessageWidget'), + message: widget.message.copyWith( + text: widget.message.text!.length > 200 + ? '${widget.message.text!.substring(0, 200)}...' + : widget.message.text, + ), + showReactions: false, + showUsername: false, + showTimestamp: false, + translateUserAvatar: false, + showSendingIndicator: false, + padding: const EdgeInsets.all(0), + showReactionPickerIndicator: widget.showReactions && + (widget.message.status == MessageSendingStatus.sent), + showPinHighlight: false, + showUserAvatar: + widget.message.user!.id == channel.client.state.user!.id + ? DisplayWidget.gone + : DisplayWidget.show, + ), onCopyTap: (message) => Clipboard.setData(ClipboardData(text: message.text)), - attachmentBorderRadiusGeometry: - widget.attachmentBorderRadiusGeometry as BorderRadius?, - showUserAvatar: - widget.message.user!.id == channel.client.state.user!.id - ? DisplayWidget.gone - : DisplayWidget.show, messageTheme: widget.messageTheme, - messageShape: widget.shape ?? _getDefaultShape(context), - attachmentShape: widget.attachmentShape ?? - _getDefaultAttachmentShape(context), reverse: widget.reverse, showDeleteMessage: widget.showDeleteMessage || isDeleteFailed, message: widget.message, @@ -968,23 +1095,35 @@ class _MessageWidgetState extends State void _showMessageReactionsModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; showDialog( + useRootNavigator: false, context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, + barrierColor: _streamChatTheme.colorTheme.overlay, builder: (context) => StreamChannel( channel: channel, child: MessageReactionsModal( - textBuilder: widget.textBuilder, - attachmentBorderRadiusGeometry: - widget.attachmentBorderRadiusGeometry as BorderRadius?, - showUserAvatar: - widget.message.user!.id == channel.client.state.user!.id - ? DisplayWidget.gone - : DisplayWidget.show, + messageWidget: widget.copyWith( + key: const Key('MessageWidget'), + message: widget.message.copyWith( + text: widget.message.text!.length > 200 + ? '${widget.message.text!.substring(0, 200)}...' + : widget.message.text, + ), + showReactions: false, + showUsername: false, + showTimestamp: false, + translateUserAvatar: false, + showSendingIndicator: false, + padding: const EdgeInsets.all(0), + showReactionPickerIndicator: widget.showReactions && + (widget.message.status == MessageSendingStatus.sent), + showPinHighlight: false, + showUserAvatar: + widget.message.user!.id == channel.client.state.user!.id + ? DisplayWidget.gone + : DisplayWidget.show, + ), onUserAvatarTap: widget.onUserAvatarTap, messageTheme: widget.messageTheme, - messageShape: widget.shape ?? _getDefaultShape(context), - attachmentShape: - widget.attachmentShape ?? _getDefaultAttachmentShape(context), reverse: widget.reverse, message: widget.message, showReactions: widget.showReactions, @@ -993,28 +1132,6 @@ class _MessageWidgetState extends State ); } - ShapeBorder _getDefaultAttachmentShape(BuildContext context) { - final hasFiles = - widget.message.attachments.any((it) => it.type == 'file') == true; - return RoundedRectangleBorder( - side: hasFiles - ? widget.attachmentBorderSide ?? - BorderSide( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, - ) - : BorderSide.none, - borderRadius: widget.attachmentBorderRadiusGeometry ?? BorderRadius.zero, - ); - } - - ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder( - side: widget.borderSide ?? - BorderSide( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, - ), - borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, - ); - Widget _parseAttachments() { final attachmentGroups = >{}; @@ -1101,7 +1218,7 @@ class _MessageWidgetState extends State Text( widget.readList!.length.toString(), style: style.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: _streamChatTheme.colorTheme.accentPrimary, ), ), const SizedBox(width: 2), @@ -1120,13 +1237,14 @@ class _MessageWidgetState extends State 2 : 0, ), - child: UserAvatar( - user: widget.message.user!, - onTap: widget.onUserAvatarTap, - constraints: widget.messageTheme.avatarTheme!.constraints, - borderRadius: widget.messageTheme.avatarTheme!.borderRadius, - showOnlineStatus: false, - ), + child: widget.userAvatarBuilder?.call(context, widget.message.user!) ?? + UserAvatar( + user: widget.message.user!, + onTap: widget.onUserAvatarTap, + constraints: widget.messageTheme.avatarTheme!.constraints, + borderRadius: widget.messageTheme.avatarTheme!.borderRadius, + showOnlineStatus: false, + ), ); Widget _buildTextBubble() { @@ -1158,7 +1276,7 @@ class _MessageWidgetState extends State Widget _buildPinnedMessage(Message message) { final pinnedBy = message.pinnedBy; - final pinnedByMe = StreamChat.of(context).user!.id == pinnedBy!.id; + final pinnedByMe = _streamChat.user!.id == pinnedBy!.id; return Padding( padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8), @@ -1174,7 +1292,7 @@ class _MessageWidgetState extends State Text( 'Pinned by ${pinnedByMe ? 'You' : pinnedBy.name}', style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.grey, + color: _streamChatTheme.colorTheme.textLowEmphasis, fontSize: 13, fontWeight: FontWeight.w400, ), @@ -1184,8 +1302,6 @@ class _MessageWidgetState extends State ); } - bool get isOnlyEmoji => widget.message.text!.isOnlyEmoji; - bool get isPinned => widget.message.pinned; Color? _getBackgroundColor() { @@ -1194,7 +1310,7 @@ class _MessageWidgetState extends State } if (hasUrlAttachments) { - return StreamChatTheme.of(context).colorTheme.blueAlice; + return _streamChatTheme.colorTheme.linkBg; } if (isOnlyEmoji) { @@ -1226,6 +1342,45 @@ class _MessageWidgetState extends State } } +class _ThreadParticipants extends StatelessWidget { + const _ThreadParticipants({ + Key? key, + required StreamChatThemeData streamChatTheme, + required this.threadParticipants, + }) : _streamChatTheme = streamChatTheme, + super(key: key); + + final StreamChatThemeData _streamChatTheme; + final Iterable threadParticipants; + + @override + Widget build(BuildContext context) { + var padding = 0.0; + return Stack( + children: threadParticipants.map((user) { + padding += 8.0; + return Positioned( + right: padding - 8, + bottom: 0, + top: 0, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _streamChatTheme.colorTheme.barsBg, + ), + padding: const EdgeInsets.all(1), + child: UserAvatar( + user: user, + constraints: BoxConstraints.loose(const Size.fromRadius(7)), + showOnlineStatus: false, + ), + ), + ); + }).toList(), + ); + } +} + class _ThreadReplyPainter extends CustomPainter { const _ThreadReplyPainter({ this.context, @@ -1240,7 +1395,7 @@ class _ThreadReplyPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() - ..color = color ?? StreamChatTheme.of(context!).colorTheme.greyGainsboro + ..color = color ?? StreamChatTheme.of(context!).colorTheme.disabled ..style = PaintingStyle.stroke ..strokeWidth = 1 ..strokeCap = StrokeCap.round; diff --git a/packages/stream_chat_flutter/lib/src/option_list_tile.dart b/packages/stream_chat_flutter/lib/src/option_list_tile.dart index 10ae1b5f..16bf9e97 100644 --- a/packages/stream_chat_flutter/lib/src/option_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/option_list_tile.dart @@ -46,11 +46,11 @@ class OptionListTile extends StatelessWidget { return Column( children: [ Container( - color: separatorColor ?? chatThemeData.colorTheme.greyGainsboro, + color: separatorColor ?? chatThemeData.colorTheme.disabled, height: 1, ), Material( - color: tileColor ?? chatThemeData.colorTheme.white, + color: tileColor ?? chatThemeData.colorTheme.barsBg, child: SizedBox( height: 63, child: InkWell( diff --git a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart index 07a820b9..22ca4d4c 100644 --- a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart @@ -1,9 +1,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:video_player/video_player.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_chat_flutter/src/extension.dart'; /// Widget builder for quoted message attachment thumnail typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function( @@ -154,7 +154,7 @@ class QuotedMessageWidget extends StatelessWidget { color: _getBackgroundColor(context), border: showBorder ? Border.all( - color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + color: StreamChatTheme.of(context).colorTheme.disabled, ) : null, borderRadius: BorderRadius.only( @@ -217,7 +217,7 @@ class QuotedMessageWidget extends StatelessWidget { } child = AbsorbPointer(child: child); return Material( - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, type: MaterialType.transparency, shape: attachment.type == 'file' ? null : _getDefaultShape(context), child: child, @@ -280,7 +280,7 @@ class QuotedMessageWidget extends StatelessWidget { Color? _getBackgroundColor(BuildContext context) { if (_containsScrapeUrl) { - return StreamChatTheme.of(context).colorTheme.blueAlice; + return StreamChatTheme.of(context).colorTheme.linkBg; } return messageTheme.messageBackgroundColor; } diff --git a/packages/stream_chat_flutter/lib/src/reaction_bubble.dart b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart index ce75445b..9e279344 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_bubble.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Creates reaction bubble widget for displaying over messages @@ -130,20 +129,20 @@ class ReactionBubble extends StatelessWidget { horizontal: 4, ), child: reactionIcon != null - ? StreamSvgIcon( - assetName: reactionIcon.assetName, - width: 16, - height: 16, - color: (!highlightOwnReactions || reaction.user?.id == userId) - ? chatThemeData.colorTheme.accentBlue - : chatThemeData.colorTheme.black.withOpacity(.5), + ? ConstrainedBox( + constraints: BoxConstraints.tight(const Size.square(16)), + child: reactionIcon.builder( + context, + !highlightOwnReactions || reaction.user?.id == userId, + 16, + ), ) : Icon( Icons.help_outline_rounded, size: 16, color: (!highlightOwnReactions || reaction.user?.id == userId) - ? chatThemeData.colorTheme.accentBlue - : chatThemeData.colorTheme.black.withOpacity(.5), + ? chatThemeData.colorTheme.accentPrimary + : chatThemeData.colorTheme.textHighEmphasis.withOpacity(.5), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/reaction_icon.dart b/packages/stream_chat_flutter/lib/src/reaction_icon.dart index 4466e154..e675128b 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_icon.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_icon.dart @@ -1,14 +1,20 @@ +import 'package:flutter/material.dart'; + /// Reaction icon data class ReactionIcon { /// Constructor for creating [ReactionIcon] ReactionIcon({ required this.type, - required this.assetName, + required this.builder, }); /// Type of reaction final String type; /// Asset to display for reaction - final String assetName; + final Widget Function( + BuildContext, + bool highlighted, + double size, + ) builder; } diff --git a/packages/stream_chat_flutter/lib/src/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reaction_picker.dart index 8a89b8c3..3e1e5e50 100644 --- a/packages/stream_chat_flutter/lib/src/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/reaction_picker.dart @@ -1,9 +1,6 @@ -import 'dart:math'; - import 'package:ezanimation/ezanimation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/extension.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker.png) @@ -50,95 +47,89 @@ class _ReactionPickerState extends State triggerAnimations(); } - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - curve: Curves.easeInOutBack, - duration: const Duration(milliseconds: 500), - builder: (context, val, wid) => Transform.scale( - scale: val, - child: Material( - borderRadius: BorderRadius.circular(24), - color: chatThemeData.colorTheme.white, - clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: reactionIcons - .map((reactionIcon) { - final ownReactionIndex = widget.message.ownReactions - ?.indexWhere((reaction) => - reaction.type == reactionIcon.type) ?? - -1; - final index = reactionIcons.indexOf(reactionIcon); + final child = Material( + borderRadius: BorderRadius.circular(24), + color: chatThemeData.colorTheme.barsBg, + clipBehavior: Clip.hardEdge, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: reactionIcons + .map((reactionIcon) { + final ownReactionIndex = widget.message.ownReactions + ?.indexWhere( + (reaction) => reaction.type == reactionIcon.type) ?? + -1; + final index = reactionIcons.indexOf(reactionIcon); - return ConstrainedBox( - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - child: RawMaterialButton( - elevation: 0, - shape: ContinuousRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction( - context, - widget.message - .ownReactions![ownReactionIndex], - ); - } else { - sendReaction( - context, - reactionIcon.type, - ); - } - }, - child: AnimatedBuilder( - animation: animations[index], - builder: (context, val) => Transform.scale( - scale: animations[index].value, - child: StreamSvgIcon( - assetName: reactionIcon.assetName, - height: max( - 0, - animations[index].value * 24.0, - ), - width: max( - 0, - animations[index].value * 24.0, - ), - color: ownReactionIndex != -1 - ? chatThemeData - .colorTheme.accentBlue - : Theme.of(context) - .iconTheme - .color! - .withOpacity(.5), - ), - )), - ), - ); - }) - .insertBetween(const SizedBox( - width: 16, - )) - .toList(), + final child = reactionIcon.builder( + context, + ownReactionIndex != -1, + 24, + ); + + return ConstrainedBox( + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, ), - ), - ), - )); + child: RawMaterialButton( + elevation: 0, + shape: ContinuousRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + onPressed: () { + if (ownReactionIndex != -1) { + removeReaction( + context, + widget.message.ownReactions![ownReactionIndex], + ); + } else { + sendReaction( + context, + reactionIcon.type, + ); + } + }, + child: AnimatedBuilder( + animation: animations[index], + builder: (context, child) => Transform.scale( + scale: animations[index].value, + child: child, + ), + child: child, + ), + ), + ); + }) + .insertBetween(const SizedBox( + width: 16, + )) + .toList(), + ), + ), + ); + + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + curve: Curves.easeInOutBack, + duration: const Duration(milliseconds: 500), + builder: (context, val, widget) => Transform.scale( + scale: val, + child: widget, + ), + child: child, + ); } void triggerAnimations() async { diff --git a/packages/stream_chat_flutter/lib/src/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/sending_indicator.dart index 88560432..7f44e5b8 100644 --- a/packages/stream_chat_flutter/lib/src/sending_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/sending_indicator.dart @@ -25,7 +25,7 @@ class SendingIndicator extends StatelessWidget { if (isMessageRead) { return StreamSvgIcon.checkAll( size: size, - color: StreamChatTheme.of(context).colorTheme.accentBlue, + color: StreamChatTheme.of(context).colorTheme.accentPrimary, ); } if (message.status == MessageSendingStatus.sent) { diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 64acae98..c050d8cb 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -101,8 +101,8 @@ class StreamChatState extends State { return Theme( data: materialTheme.copyWith( primaryIconTheme: streamTheme.primaryIconTheme, - accentColor: streamTheme.colorTheme.accentBlue, - scaffoldBackgroundColor: streamTheme.colorTheme.white, + accentColor: streamTheme.colorTheme.accentPrimary, + scaffoldBackgroundColor: streamTheme.colorTheme.barsBg, ), child: StreamChatCore( client: client, diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index 25c2ca32..72b04a80 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -114,7 +114,7 @@ class StreamChatThemeData { final defaultTheme = StreamChatThemeData(brightness: theme.brightness); final customizedTheme = StreamChatThemeData.fromColorAndTextTheme( defaultTheme.colorTheme.copyWith( - accentBlue: theme.accentColor, + accentPrimary: theme.accentColor, ), defaultTheme.textTheme, ); @@ -216,11 +216,13 @@ class StreamChatThemeData { ColorTheme colorTheme, TextTheme textTheme, ) { - final accentColor = colorTheme.accentBlue; + final accentColor = colorTheme.accentPrimary; + final iconTheme = + IconThemeData(color: colorTheme.textHighEmphasis.withOpacity(.5)); return StreamChatThemeData.raw( textTheme: textTheme, colorTheme: colorTheme, - primaryIconTheme: IconThemeData(color: colorTheme.black.withOpacity(.5)), + primaryIconTheme: iconTheme, defaultChannelImage: (context, channel) => const SizedBox(), defaultUserImage: (context, user) => Center( child: CachedNetworkImage( @@ -230,7 +232,7 @@ class StreamChatThemeData { ), ), channelPreviewTheme: ChannelPreviewTheme( - unreadCounterColor: colorTheme.accentRed, + unreadCounterColor: colorTheme.accentError, avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(20), constraints: const BoxConstraints.tightFor( @@ -243,7 +245,7 @@ class StreamChatThemeData { color: const Color(0xff7A7A7A), ), lastMessageAt: textTheme.footnote.copyWith( - color: colorTheme.black.withOpacity(.5), + color: colorTheme.textHighEmphasis.withOpacity(.5), ), indicatorIconSize: 16, ), @@ -255,7 +257,7 @@ class StreamChatThemeData { width: 40, ), ), - color: colorTheme.white, + color: colorTheme.barsBg, title: textTheme.headlineBold, ), channelTheme: ChannelTheme( @@ -267,7 +269,7 @@ class StreamChatThemeData { width: 40, ), ), - color: colorTheme.white, + color: colorTheme.barsBg, title: textTheme.headlineBold, subtitle: textTheme.footnote.copyWith( color: const Color(0xff7A7A7A), @@ -275,15 +277,17 @@ class StreamChatThemeData { ), ), ownMessageTheme: MessageTheme( - messageAuthor: textTheme.footnote.copyWith(color: colorTheme.grey), + messageAuthor: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), messageText: textTheme.body, - createdAt: textTheme.footnote.copyWith(color: colorTheme.grey), + createdAt: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), replies: textTheme.footnoteBold.copyWith(color: accentColor), - messageBackgroundColor: colorTheme.greyGainsboro, - reactionsBackgroundColor: colorTheme.white, - reactionsBorderColor: colorTheme.greyWhisper, - reactionsMaskColor: colorTheme.whiteSnow, - messageBorderColor: colorTheme.greyGainsboro, + messageBackgroundColor: colorTheme.disabled, + reactionsBackgroundColor: colorTheme.barsBg, + reactionsBorderColor: colorTheme.borders, + reactionsMaskColor: colorTheme.appBg, + messageBorderColor: colorTheme.disabled, avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(20), constraints: const BoxConstraints.tightFor( @@ -296,18 +300,20 @@ class StreamChatThemeData { ), ), otherMessageTheme: MessageTheme( - reactionsBackgroundColor: colorTheme.greyGainsboro, - reactionsBorderColor: colorTheme.white, - reactionsMaskColor: colorTheme.whiteSnow, + reactionsBackgroundColor: colorTheme.disabled, + reactionsBorderColor: colorTheme.barsBg, + reactionsMaskColor: colorTheme.appBg, messageText: textTheme.body, - createdAt: textTheme.footnote.copyWith(color: colorTheme.grey), - messageAuthor: textTheme.footnote.copyWith(color: colorTheme.grey), + createdAt: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), + messageAuthor: + textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), replies: textTheme.footnoteBold.copyWith(color: accentColor), messageLinks: TextStyle( color: accentColor, ), - messageBackgroundColor: colorTheme.white, - messageBorderColor: colorTheme.greyWhisper, + messageBackgroundColor: colorTheme.barsBg, + messageBorderColor: colorTheme.borders, avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(20), constraints: const BoxConstraints.tightFor( @@ -319,46 +325,86 @@ class StreamChatThemeData { messageInputTheme: MessageInputTheme( borderRadius: BorderRadius.circular(20), sendAnimationDuration: const Duration(milliseconds: 300), - actionButtonColor: colorTheme.accentBlue, - actionButtonIdleColor: colorTheme.grey, - expandButtonColor: colorTheme.accentBlue, - sendButtonColor: colorTheme.accentBlue, - sendButtonIdleColor: colorTheme.greyGainsboro, - inputBackground: colorTheme.white, + actionButtonColor: colorTheme.accentPrimary, + actionButtonIdleColor: colorTheme.textLowEmphasis, + expandButtonColor: colorTheme.accentPrimary, + sendButtonColor: colorTheme.accentPrimary, + sendButtonIdleColor: colorTheme.disabled, + inputBackground: colorTheme.barsBg, inputTextStyle: textTheme.body, idleBorderGradient: LinearGradient( colors: [ - colorTheme.greyGainsboro, - colorTheme.greyGainsboro, + colorTheme.disabled, + colorTheme.disabled, ], ), activeBorderGradient: LinearGradient( colors: [ - colorTheme.greyGainsboro, - colorTheme.greyGainsboro, + colorTheme.disabled, + colorTheme.disabled, ], ), ), reactionIcons: [ ReactionIcon( type: 'love', - assetName: 'Icon_love_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.loveReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'like', - assetName: 'Icon_thumbs_up_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.thumbsUpReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'sad', - assetName: 'Icon_thumbs_down_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.thumbsDownReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'haha', - assetName: 'Icon_LOL_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.lolReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ReactionIcon( type: 'wow', - assetName: 'Icon_wut_reaction.svg', + builder: (context, highlighted, size) { + final theme = StreamChatTheme.of(context); + return StreamSvgIcon.wutReaction( + color: highlighted + ? theme.colorTheme.accentPrimary + : theme.primaryIconTheme.color!.withOpacity(.5), + size: size, + ); + }, ), ], ); @@ -531,17 +577,17 @@ class TextTheme { class ColorTheme { /// Initialise with light theme ColorTheme.light({ - this.black = const Color(0xff000000), - this.grey = const Color(0xff7a7a7a), - this.greyGainsboro = const Color(0xffdbdbdb), - this.greyWhisper = const Color(0xffecebeb), - this.whiteSmoke = const Color(0xfff2f2f2), - this.whiteSnow = const Color(0xfffcfcfc), - this.white = const Color(0xffffffff), - this.blueAlice = const Color(0xffe9f2ff), - this.accentBlue = const Color(0xff005FFF), - this.accentRed = const Color(0xffFF3842), - this.accentGreen = const Color(0xff20E070), + this.textHighEmphasis = const Color(0xff000000), + this.textLowEmphasis = const Color(0xff7a7a7a), + this.disabled = const Color(0xffdbdbdb), + this.borders = const Color(0xffecebeb), + this.inputBg = const Color(0xfff2f2f2), + this.appBg = const Color(0xfffcfcfc), + this.barsBg = const Color(0xffffffff), + this.linkBg = const Color(0xffe9f2ff), + this.accentPrimary = const Color(0xff005FFF), + this.accentError = const Color(0xffFF3842), + this.accentInfo = const Color(0xff20E070), this.highlight = const Color(0xfffbf4dd), this.overlay = const Color.fromRGBO(0, 0, 0, 0.2), this.overlayDark = const Color.fromRGBO(0, 0, 0, 0.6), @@ -563,17 +609,17 @@ class ColorTheme { /// Initialise with dark theme ColorTheme.dark({ - this.black = const Color(0xffffffff), - this.grey = const Color(0xff7a7a7a), - this.greyGainsboro = const Color(0xff2d2f2f), - this.greyWhisper = const Color(0xff1c1e22), - this.whiteSmoke = const Color(0xff13151b), - this.whiteSnow = const Color(0xff070A0D), - this.white = const Color(0xff101418), - this.blueAlice = const Color(0xff00193D), - this.accentBlue = const Color(0xff005FFF), - this.accentRed = const Color(0xffFF3742), - this.accentGreen = const Color(0xff20E070), + this.textHighEmphasis = const Color(0xffffffff), + this.textLowEmphasis = const Color(0xff7a7a7a), + this.disabled = const Color(0xff2d2f2f), + this.borders = const Color(0xff1c1e22), + this.inputBg = const Color(0xff13151b), + this.appBg = const Color(0xff070A0D), + this.barsBg = const Color(0xff101418), + this.linkBg = const Color(0xff00193D), + this.accentPrimary = const Color(0xff005FFF), + this.accentError = const Color(0xffFF3742), + this.accentInfo = const Color(0xff20E070), this.borderTop = const Effect( sigmaX: 0, sigmaY: -1, @@ -616,37 +662,37 @@ class ColorTheme { }) : brightness = Brightness.dark; /// - final Color black; + final Color textHighEmphasis; /// - final Color grey; + final Color textLowEmphasis; /// - final Color greyGainsboro; + final Color disabled; /// - final Color greyWhisper; + final Color borders; /// - final Color whiteSmoke; + final Color inputBg; /// - final Color whiteSnow; + final Color appBg; /// - final Color white; + final Color barsBg; /// - final Color blueAlice; + final Color linkBg; /// - final Color accentBlue; + final Color accentPrimary; /// - final Color accentRed; + final Color accentError; /// - final Color accentGreen; + final Color accentInfo; /// final Effect borderTop; @@ -678,17 +724,17 @@ class ColorTheme { /// Copy with theme ColorTheme copyWith({ Brightness brightness = Brightness.light, - Color? black, - Color? grey, - Color? greyGainsboro, - Color? greyWhisper, - Color? whiteSmoke, - Color? whiteSnow, - Color? white, - Color? blueAlice, - Color? accentBlue, - Color? accentRed, - Color? accentGreen, + Color? textHighEmphasis, + Color? textLowEmphasis, + Color? disabled, + Color? borders, + Color? inputBg, + Color? appBg, + Color? barsBg, + Color? linkBg, + Color? accentPrimary, + Color? accentError, + Color? accentInfo, Effect? borderTop, Effect? borderBottom, Effect? shadowIconButton, @@ -700,17 +746,17 @@ class ColorTheme { }) => brightness == Brightness.light ? ColorTheme.light( - black: black ?? this.black, - grey: grey ?? this.grey, - greyGainsboro: greyGainsboro ?? this.greyGainsboro, - greyWhisper: greyWhisper ?? this.greyWhisper, - whiteSmoke: whiteSmoke ?? this.whiteSmoke, - whiteSnow: whiteSnow ?? this.whiteSnow, - white: white ?? this.white, - blueAlice: blueAlice ?? this.blueAlice, - accentBlue: accentBlue ?? this.accentBlue, - accentRed: accentRed ?? this.accentRed, - accentGreen: accentGreen ?? this.accentGreen, + textHighEmphasis: textHighEmphasis ?? this.textHighEmphasis, + textLowEmphasis: textLowEmphasis ?? this.textLowEmphasis, + disabled: disabled ?? this.disabled, + borders: borders ?? this.borders, + inputBg: inputBg ?? this.inputBg, + appBg: appBg ?? this.appBg, + barsBg: barsBg ?? this.barsBg, + linkBg: linkBg ?? this.linkBg, + accentPrimary: accentPrimary ?? this.accentPrimary, + accentError: accentError ?? this.accentError, + accentInfo: accentInfo ?? this.accentInfo, borderTop: borderTop ?? this.borderTop, borderBottom: borderBottom ?? this.borderBottom, shadowIconButton: shadowIconButton ?? this.shadowIconButton, @@ -721,17 +767,17 @@ class ColorTheme { bgGradient: bgGradient ?? this.bgGradient, ) : ColorTheme.dark( - black: black ?? this.black, - grey: grey ?? this.grey, - greyGainsboro: greyGainsboro ?? this.greyGainsboro, - greyWhisper: greyWhisper ?? this.greyWhisper, - whiteSmoke: whiteSmoke ?? this.whiteSmoke, - whiteSnow: whiteSnow ?? this.whiteSnow, - white: white ?? this.white, - blueAlice: blueAlice ?? this.blueAlice, - accentBlue: accentBlue ?? this.accentBlue, - accentRed: accentRed ?? this.accentRed, - accentGreen: accentGreen ?? this.accentGreen, + textHighEmphasis: textHighEmphasis ?? this.textHighEmphasis, + textLowEmphasis: textLowEmphasis ?? this.textLowEmphasis, + disabled: disabled ?? this.disabled, + borders: borders ?? this.borders, + inputBg: inputBg ?? this.inputBg, + appBg: appBg ?? this.appBg, + barsBg: barsBg ?? this.barsBg, + linkBg: linkBg ?? this.linkBg, + accentPrimary: accentPrimary ?? this.accentPrimary, + accentError: accentError ?? this.accentError, + accentInfo: accentInfo ?? this.accentInfo, borderTop: borderTop ?? this.borderTop, borderBottom: borderBottom ?? this.borderBottom, shadowIconButton: shadowIconButton ?? this.shadowIconButton, @@ -746,17 +792,17 @@ class ColorTheme { ColorTheme merge(ColorTheme? other) { if (other == null) return this; return copyWith( - black: other.black, - grey: other.grey, - greyGainsboro: other.greyGainsboro, - greyWhisper: other.greyWhisper, - whiteSmoke: other.whiteSmoke, - whiteSnow: other.whiteSnow, - white: other.white, - blueAlice: other.blueAlice, - accentBlue: other.accentBlue, - accentRed: other.accentRed, - accentGreen: other.accentGreen, + textHighEmphasis: other.textHighEmphasis, + textLowEmphasis: other.textLowEmphasis, + disabled: other.disabled, + borders: other.borders, + inputBg: other.inputBg, + appBg: other.appBg, + barsBg: other.barsBg, + linkBg: other.linkBg, + accentPrimary: other.accentPrimary, + accentError: other.accentError, + accentInfo: other.accentInfo, highlight: other.highlight, overlay: other.overlay, overlayDark: other.overlayDark, diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart index ae344a89..fb5017e1 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -49,6 +49,66 @@ class StreamSvgIcon extends StatelessWidget { height: size, ); + /// [StreamSvgIcon] type + factory StreamSvgIcon.loveReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_love_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.thumbsUpReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_thumbs_up_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.thumbsDownReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_thumbs_down_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.lolReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_LOL_reaction.svg', + color: color, + width: size, + height: size, + ); + + /// [StreamSvgIcon] type + factory StreamSvgIcon.wutReaction({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'Icon_wut_reaction.svg', + color: color, + width: size, + height: size, + ); + /// [StreamSvgIcon] type factory StreamSvgIcon.smile({ double? size, diff --git a/packages/stream_chat_flutter/lib/src/swipeable.dart b/packages/stream_chat_flutter/lib/src/swipeable.dart index 5895e8b5..041111e4 100644 --- a/packages/stream_chat_flutter/lib/src/swipeable.dart +++ b/packages/stream_chat_flutter/lib/src/swipeable.dart @@ -151,9 +151,8 @@ class _SwipeableState extends State with TickerProviderStateMixin { decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: StreamChatTheme.of(context) - .colorTheme - .greyGainsboro, + color: + StreamChatTheme.of(context).colorTheme.disabled, ), ), child: widget.backgroundIcon, diff --git a/packages/stream_chat_flutter/lib/src/system_message.dart b/packages/stream_chat_flutter/lib/src/system_message.dart index b847eb3c..11c80300 100644 --- a/packages/stream_chat_flutter/lib/src/system_message.dart +++ b/packages/stream_chat_flutter/lib/src/system_message.dart @@ -32,7 +32,7 @@ class SystemMessage extends StatelessWidget { textAlign: TextAlign.center, softWrap: true, style: theme.textTheme.captionBold.copyWith( - color: theme.colorTheme.grey, + color: theme.colorTheme.textLowEmphasis, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/thread_header.dart b/packages/stream_chat_flutter/lib/src/thread_header.dart index d16aa530..bfde9a4c 100644 --- a/packages/stream_chat_flutter/lib/src/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/thread_header.dart @@ -68,6 +68,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { this.leading, this.actions, this.onTitleTap, + this.showTypingIndicator = true, }) : preferredSize = const Size.fromHeight(kToolbarHeight), super(key: key); @@ -96,9 +97,32 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { /// AppBar actions final List? actions; + /// If true the typing indicator will be rendered + /// if a user is typing in this thread + final bool showTypingIndicator; + @override Widget build(BuildContext context) { final chatThemeData = StreamChatTheme.of(context); + + final defaultSubtitle = subtitle ?? + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'with ', + style: chatThemeData.channelTheme.channelHeaderTheme.subtitle, + ), + Flexible( + child: ChannelName( + textStyle: + chatThemeData.channelTheme.channelHeaderTheme.subtitle, + ), + ), + ], + ); + return AppBar( automaticallyImplyLeading: false, textTheme: Theme.of(context).textTheme, @@ -119,6 +143,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { onTap: onTitleTap, child: SizedBox( height: preferredSize.height, + width: 250, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -128,24 +153,16 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { style: chatThemeData.channelTheme.channelHeaderTheme.title, ), const SizedBox(height: 2), - subtitle ?? - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'with ', - style: chatThemeData - .channelTheme.channelHeaderTheme.subtitle, - ), - Flexible( - child: ChannelName( - textStyle: chatThemeData - .channelTheme.channelHeaderTheme.subtitle, - ), - ), - ], - ), + if (showTypingIndicator) + TypingIndicator( + alignment: Alignment.center, + channel: StreamChannel.of(context).channel, + style: chatThemeData.channelTheme.channelHeaderTheme.subtitle, + parentId: parent.id, + alternativeWidget: defaultSubtitle, + ) + else + defaultSubtitle, ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/typing_indicator.dart index e9377674..4c0ad19f 100644 --- a/packages/stream_chat_flutter/lib/src/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/typing_indicator.dart @@ -12,6 +12,7 @@ class TypingIndicator extends StatelessWidget { this.style, this.alignment = Alignment.centerLeft, this.padding = const EdgeInsets.all(0), + this.parentId, }) : super(key: key); /// Style of the text widget @@ -29,17 +30,26 @@ class TypingIndicator extends StatelessWidget { /// Alignment of the typing indicator final Alignment alignment; + /// Id of the parent message in case of a thread + final String? parentId; + @override Widget build(BuildContext context) { final channelState = channel?.state ?? StreamChannel.of(context).channel.state!; - return StreamBuilder>( - initialData: channelState.typingEvents, - stream: channelState.typingEventsStream, - builder: (context, snapshot) => AnimatedSwitcher( + + final altWidget = alternativeWidget ?? const Offstage(); + + return BetterStreamBuilder>( + initialData: channelState.typingEvents.keys, + stream: channelState.typingEventsStream.map((typings) => typings.entries + .where((element) => element.value.parentId == parentId) + .map((e) => e.key)), + builder: (context, data) => AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: snapshot.data?.isNotEmpty == true + child: data.isNotEmpty == true ? Padding( + key: const Key('main'), padding: padding, child: Align( key: const Key('typings'), @@ -54,7 +64,7 @@ class TypingIndicator extends StatelessWidget { ), Text( // ignore: lines_longer_than_80_chars - ' ${snapshot.data![0].name}${snapshot.data!.length == 1 ? '' : ' and ${snapshot.data!.length - 1} more'} ${snapshot.data!.length == 1 ? 'is' : 'are'} typing', + ' ${data.elementAt(0).name}${data.length == 1 ? '' : ' and ${data.length - 1} more'} ${data.length == 1 ? 'is' : 'are'} typing', maxLines: 1, style: style, ), @@ -62,13 +72,7 @@ class TypingIndicator extends StatelessWidget { ), ), ) - : Align( - key: const Key('alternative'), - alignment: alignment, - child: Container( - child: alternativeWidget ?? const Offstage(), - ), - ), + : altWidget, ), ); } diff --git a/packages/stream_chat_flutter/lib/src/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/unread_indicator.dart index 9c3a5f88..381bad39 100644 --- a/packages/stream_chat_flutter/lib/src/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/unread_indicator.dart @@ -17,16 +17,16 @@ class UnreadIndicator extends StatelessWidget { Widget build(BuildContext context) { final client = StreamChat.of(context).client; return IgnorePointer( - child: StreamBuilder( + child: BetterStreamBuilder( stream: cid != null ? client.state.channels[cid]?.state?.unreadCountStream : client.state.totalUnreadCountStream, initialData: cid != null ? client.state.channels[cid]?.state?.unreadCount : client.state.totalUnreadCount, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == 0) { - return const SizedBox(); + builder: (context, data) { + if (data == null || data == 0) { + return const Offstage(); } return Material( borderRadius: BorderRadius.circular(8), @@ -42,7 +42,7 @@ class UnreadIndicator extends StatelessWidget { ), child: Center( child: Text( - '${snapshot.data! > 99 ? '99+' : snapshot.data}', + '${data > 99 ? '99+' : data}', style: const TextStyle( fontSize: 11, color: Colors.white, diff --git a/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart index 9905141a..b3fe510d 100644 --- a/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart @@ -59,7 +59,7 @@ class UploadProgressIndicator extends StatelessWidget { '${_percentage.toInt()}%', style: textStyle ?? theme.textTheme.footnote.copyWith( - color: theme.colorTheme.white, + color: theme.colorTheme.barsBg, ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/url_attachment.dart b/packages/stream_chat_flutter/lib/src/url_attachment.dart index 995475a8..5d5ca489 100644 --- a/packages/stream_chat_flutter/lib/src/url_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/url_attachment.dart @@ -40,7 +40,7 @@ class UrlAttachment extends StatelessWidget { children: [ if (urlAttachment.imageUrl != null) Container( - clipBehavior: Clip.antiAliasWithSaveLayer, + clipBehavior: Clip.hardEdge, margin: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), @@ -60,7 +60,7 @@ class UrlAttachment extends StatelessWidget { borderRadius: const BorderRadius.only( topRight: Radius.circular(16), ), - color: chatThemeData.colorTheme.blueAlice, + color: chatThemeData.colorTheme.linkBg, ), child: Padding( padding: const EdgeInsets.only( @@ -71,7 +71,7 @@ class UrlAttachment extends StatelessWidget { child: Text( hostDisplayName, style: chatThemeData.textTheme.bodyBold.copyWith( - color: chatThemeData.colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/user_avatar.dart b/packages/stream_chat_flutter/lib/src/user_avatar.dart index ecf1783f..31e07f0a 100644 --- a/packages/stream_chat_flutter/lib/src/user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/user_avatar.dart @@ -70,7 +70,7 @@ class UserAvatar extends StatelessWidget { constraints: constraints ?? streamChatTheme.ownMessageTheme.avatarTheme?.constraints, decoration: BoxDecoration( - color: streamChatTheme.colorTheme.accentBlue, + color: streamChatTheme.colorTheme.accentPrimary, ), child: hasImage ? CachedNetworkImage( @@ -95,7 +95,7 @@ class UserAvatar extends StatelessWidget { child: Container( constraints: constraints ?? streamChatTheme.ownMessageTheme.avatarTheme?.constraints, - color: selectionColor ?? streamChatTheme.colorTheme.accentBlue, + color: selectionColor ?? streamChatTheme.colorTheme.accentPrimary, child: Padding( padding: EdgeInsets.all(selectionThickness), child: avatar, @@ -115,7 +115,7 @@ class UserAvatar extends StatelessWidget { alignment: onlineIndicatorAlignment, child: Material( type: MaterialType.circle, - color: streamChatTheme.colorTheme.white, + color: streamChatTheme.colorTheme.barsBg, child: Container( margin: const EdgeInsets.all(2), constraints: onlineIndicatorConstraints ?? @@ -125,7 +125,7 @@ class UserAvatar extends StatelessWidget { ), child: Material( shape: const CircleBorder(), - color: streamChatTheme.colorTheme.accentGreen, + color: streamChatTheme.colorTheme.accentInfo, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/user_item.dart b/packages/stream_chat_flutter/lib/src/user_item.dart index e9d9f7c0..a13b544e 100644 --- a/packages/stream_chat_flutter/lib/src/user_item.dart +++ b/packages/stream_chat_flutter/lib/src/user_item.dart @@ -75,7 +75,7 @@ class UserItem extends StatelessWidget { ), trailing: selected ? StreamSvgIcon.checkSend( - color: chatThemeData.colorTheme.accentBlue, + color: chatThemeData.colorTheme.accentPrimary, ) : null, title: Text( @@ -92,8 +92,8 @@ class UserItem extends StatelessWidget { user.online == true ? 'Online' : 'Last online ${Jiffy(user.lastActive).fromNow()}', - style: chatTheme.textTheme.footnote - .copyWith(color: chatTheme.colorTheme.black.withOpacity(.5)), + style: chatTheme.textTheme.footnote.copyWith( + color: chatTheme.colorTheme.textHighEmphasis.withOpacity(.5)), ); } } diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart index 3b1efd04..923e0334 100644 --- a/packages/stream_chat_flutter/lib/src/user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -47,8 +47,8 @@ class UserListView extends StatefulWidget { const UserListView({ Key? key, this.filter, - this.options, this.sort, + this.presence, this.pagination, this.onUserTap, this.onUserLongPress, @@ -64,6 +64,7 @@ class UserListView extends StatefulWidget { this.emptyBuilder, this.loadingBuilder, this.listBuilder, + this.userListController, }) : assert( crossAxisCount == 1 || groupAlphabetically == false, 'Cannot group alphabetically when crossAxisCount > 1', @@ -75,12 +76,6 @@ class UserListView extends StatefulWidget { /// You can also filter other built-in channel fields. final Filter? filter; - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map? options; - /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can /// be provided. @@ -89,6 +84,9 @@ class UserListView extends StatefulWidget { /// Direction can be ascending or descending. final List? sort; + /// If true you’ll receive user presence updates via the websocket events + final bool? presence; + /// Pagination parameters /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) @@ -131,7 +129,7 @@ class UserListView extends StatefulWidget { final int crossAxisCount; /// The builder that will be used in case of error - final Widget Function(Error error)? errorBuilder; + final ErrorBuilder? errorBuilder; /// The builder that will be used to build the list final Widget Function(BuildContext context, List users)? @@ -143,6 +141,11 @@ class UserListView extends StatefulWidget { /// The builder used when the channel list is empty. final WidgetBuilder? emptyBuilder; + /// A [UserListController] allows reloading and pagination. + /// Use [UserListController.loadData] and [UserListController.paginateData] + /// respectively for reloading and pagination. + final UserListController? userListController; + @override _UserListViewState createState() => _UserListViewState(); } @@ -151,13 +154,15 @@ class _UserListViewState extends State with WidgetsBindingObserver { bool get _isListView => widget.crossAxisCount == 1; - final UserListController _userListController = UserListController(); + late final _defaultController = UserListController(); + UserListController get _userListController => + widget.userListController ?? _defaultController; @override Widget build(BuildContext context) { final child = UserListCore( - errorBuilder: widget.errorBuilder as Widget Function(Object)? ?? - (err) => _buildError(err as Error), + errorBuilder: widget.errorBuilder ?? + (BuildContext context, Object err) => _buildError(err), emptyBuilder: widget.emptyBuilder ?? (context) => _buildEmpty(), loadingBuilder: widget.loadingBuilder ?? (context) => LayoutBuilder( @@ -177,9 +182,9 @@ class _UserListViewState extends State listBuilder: widget.listBuilder ?? (context, list) => _buildListView(list), pagination: widget.pagination, - options: widget.options, sort: widget.sort, filter: widget.filter, + presence: widget.presence, groupAlphabetically: widget.groupAlphabetically, userListController: _userListController, ); @@ -197,52 +202,33 @@ class _UserListViewState extends State bool get isListAlreadySorted => widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; - Widget _buildError(Error error) { - print(error.stackTrace); - - var message = error.toString(); - if (error is DioError) { - final dioError = error as DioError; - if (dioError.type == DioErrorType.response) { - message = dioError.message; - } else { - message = 'Check your connection and retry'; - } - } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text.rich( - const TextSpan( - children: [ - WidgetSpan( - child: Padding( - padding: EdgeInsets.only( - right: 2, + Widget _buildError(Object error) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + const TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: EdgeInsets.only( + right: 2, + ), + child: Icon(Icons.error_outline), ), - child: Icon(Icons.error_outline), ), - ), - TextSpan(text: 'Error loading channels'), - ], + TextSpan(text: 'Error loading users'), + ], + ), + style: Theme.of(context).textTheme.headline6, ), - style: Theme.of(context).textTheme.headline6, - ), - Padding( - padding: const EdgeInsets.only( - top: 16, + TextButton( + onPressed: () => _userListController.loadData!(), + child: const Text('Retry'), ), - child: Text(message), - ), - TextButton( - onPressed: () => _userListController.loadData!(), - child: const Text('Retry'), - ), - ], - ), - ); - } + ], + ), + ); Widget _buildEmpty() => LayoutBuilder( builder: (context, viewportConstraints) => SingleChildScrollView( @@ -299,7 +285,7 @@ class _UserListViewState extends State final chatThemeData = StreamChatTheme.of(context); return Container( key: ValueKey('HEADER-$header'), - color: chatThemeData.colorTheme.black.withOpacity(0.05), + color: chatThemeData.colorTheme.textHighEmphasis.withOpacity(0.05), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Text( @@ -307,7 +293,7 @@ class _UserListViewState extends State style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14.5, - color: chatThemeData.colorTheme.grey, + color: chatThemeData.colorTheme.textLowEmphasis, ), ), ), @@ -399,7 +385,7 @@ class _UserListViewState extends State return Container( color: StreamChatTheme.of(context) .colorTheme - .accentRed + .accentError .withOpacity(.2), child: const Padding( padding: EdgeInsets.symmetric(vertical: 16), @@ -422,6 +408,6 @@ class _UserListViewState extends State Widget _separatorBuilder(context, i) => Container( height: 1, - color: StreamChatTheme.of(context).colorTheme.greyWhisper, + color: StreamChatTheme.of(context).colorTheme.borders, ); } diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 5a86d4ef..aee46800 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -28,7 +30,8 @@ Future showConfirmationDialog( }) { final chatThemeData = StreamChatTheme.of(context); return showModalBottomSheet( - backgroundColor: chatThemeData.colorTheme.white, + useRootNavigator: false, + backgroundColor: chatThemeData.colorTheme.barsBg, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -72,7 +75,7 @@ Future showConfirmationDialog( child: Text( cancelText, style: chatThemeData.textTheme.bodyBold.copyWith( - color: chatThemeData.colorTheme.black + color: chatThemeData.colorTheme.textHighEmphasis .withOpacity(0.5)), ), ), @@ -88,7 +91,7 @@ Future showConfirmationDialog( child: Text( okText, style: chatThemeData.textTheme.bodyBold.copyWith( - color: chatThemeData.colorTheme.accentRed), + color: chatThemeData.colorTheme.accentError), ), ), ), @@ -112,7 +115,9 @@ Future showInfoDialog( }) { final chatThemeData = StreamChatTheme.of(context); return showModalBottomSheet( - backgroundColor: theme?.colorTheme.white ?? chatThemeData.colorTheme.white, + useRootNavigator: false, + backgroundColor: + theme?.colorTheme.barsBg ?? chatThemeData.colorTheme.barsBg, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -143,8 +148,8 @@ Future showInfoDialog( height: 36, ), Container( - color: theme?.colorTheme.black.withOpacity(.08) ?? - chatThemeData.colorTheme.black.withOpacity(.08), + color: theme?.colorTheme.textHighEmphasis.withOpacity(.08) ?? + chatThemeData.colorTheme.textHighEmphasis.withOpacity(.08), height: 1, ), Center( @@ -155,8 +160,8 @@ Future showInfoDialog( child: Text( okText, style: TextStyle( - color: theme?.colorTheme.black.withOpacity(0.5) ?? - chatThemeData.colorTheme.accentBlue, + color: theme?.colorTheme.textHighEmphasis.withOpacity(0.5) ?? + chatThemeData.colorTheme.accentPrimary, fontWeight: FontWeight.w400, ), ), @@ -332,7 +337,7 @@ Widget wrapAttachmentWidget( bool reverse, ) => Material( - clipBehavior: Clip.antiAlias, + clipBehavior: Clip.hardEdge, shape: attachmentShape, type: MaterialType.transparency, child: attachmentWidget, diff --git a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart index 23cc0e35..2286bc3d 100644 --- a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart +++ b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart @@ -94,8 +94,8 @@ class _VideoThumbnailImageState extends State { constraints: const BoxConstraints.expand(), child: widget.placeholderBuilder?.call(context) ?? Shimmer.fromColors( - baseColor: _streamChatTheme.colorTheme.greyGainsboro, - highlightColor: _streamChatTheme.colorTheme.whiteSmoke, + baseColor: _streamChatTheme.colorTheme.disabled, + highlightColor: _streamChatTheme.colorTheme.inputBg, child: Image.asset( 'images/placeholder.png', fit: BoxFit.cover, diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 2aa5ab4a..342914d4 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 2.0.0-nullsafety.4 +version: 2.0.0-nullsafety.8 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -11,7 +11,7 @@ environment: dependencies: cached_network_image: ^3.0.0 characters: ^1.1.0 - chewie: ^1.0.0 + chewie: ^1.2.0 collection: ^1.15.0 dio: ^4.0.0 ezanimation: ^0.5.0 @@ -25,7 +25,7 @@ dependencies: flutter_svg: ^0.22.0 http_parser: ^4.0.0 image_gallery_saver: ^1.6.9 - image_picker: ^0.7.4 + image_picker: ^0.8.0 jiffy: ^4.1.0 lottie: ^1.0.1 meta: ^1.3.0 @@ -36,12 +36,12 @@ dependencies: scrollable_positioned_list: ^0.2.0-nullsafety.0 share_plus: ^2.0.3 shimmer: ^2.0.0 - stream_chat_flutter_core: ^2.0.0-nullsafety.3 + stream_chat_flutter_core: ^2.0.0-nullsafety.8 substring_highlight: ^1.0.26 synchronized: ^3.0.0 url_launcher: ^6.0.3 video_compress: ^3.0.0 - video_player: ^2.1.1 + video_player: ^2.1.0 video_thumbnail: ^0.3.3 visibility_detector: ^0.2.0 diff --git a/packages/stream_chat_flutter/test/src/back_button_test.dart b/packages/stream_chat_flutter/test/src/back_button_test.dart index 3fae1ace..efb889ac 100644 --- a/packages/stream_chat_flutter/test/src/back_button_test.dart +++ b/packages/stream_chat_flutter/test/src/back_button_test.dart @@ -118,8 +118,9 @@ void main() { final clientState = MockClientState(); when(() => client.state).thenReturn(clientState); + when(() => clientState.totalUnreadCount).thenAnswer((_) => 0); when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(0)); + .thenAnswer((_) => Stream.value(0)); await tester.pumpWidget( MaterialApp( diff --git a/packages/stream_chat_flutter/test/src/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel_header_test.dart index 6da3e20f..87f3d028 100644 --- a/packages/stream_chat_flutter/test/src/channel_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_header_test.dart @@ -34,6 +34,7 @@ void main() { .thenAnswer((_) => Stream.value(ConnectionStatus.connected)); when(() => channelState.unreadCountStream) .thenAnswer((i) => Stream.value(1)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream) .thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer((i) => Stream.value([ @@ -107,6 +108,9 @@ void main() { ]); when(() => client.wsConnectionStatusStream) .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); + when(() => client.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream) .thenAnswer((i) => Stream.value(1)); @@ -169,6 +173,7 @@ void main() { ]); when(() => client.wsConnectionStatusStream) .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream) .thenAnswer((i) => Stream.value(1)); @@ -373,6 +378,7 @@ void main() { ]); when(() => client.wsConnectionStatusStream) .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream) .thenAnswer((i) => Stream.value(1)); 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 b913489e..baa82614 100644 --- a/packages/stream_chat_flutter/test/src/channel_preview_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_preview_test.dart @@ -60,10 +60,6 @@ void main() { ) ])); - when(() => channelState.typingEvents).thenReturn([]); - when(() => channelState.typingEventsStream) - .thenAnswer((_) => Stream.value([])); - await tester.pumpWidget(MaterialApp( home: StreamChat( client: client, diff --git a/packages/stream_chat_flutter/test/src/full_screen_media_test.dart b/packages/stream_chat_flutter/test/src/full_screen_media_test.dart index 81c342eb..3e6f1743 100644 --- a/packages/stream_chat_flutter/test/src/full_screen_media_test.dart +++ b/packages/stream_chat_flutter/test/src/full_screen_media_test.dart @@ -53,15 +53,15 @@ void main() { user: User(id: 'other-user'), ) ])); - - when(() => channelState.typingEvents).thenAnswer((i) => [ - User(id: 'other-user', extraData: {'name': 'demo'}) - ]); + when(() => channelState.typingEvents).thenAnswer((i) => { + User(id: 'other-user', extraData: {'name': 'demo'}): + Event(type: EventType.typingStart), + }); when(() => channelState.typingEventsStream) - .thenAnswer((i) => Stream.value([ - User(id: 'other-user', extraData: {'name': 'demo'}), - User(id: 'other-user', extraData: {'name': 'demo'}), - ])); + .thenAnswer((i) => Stream.value({ + User(id: 'other-user', extraData: {'name': 'demo'}): + Event(type: EventType.typingStart), + })); await tester.pumpWidget(MaterialApp( home: StreamChat( diff --git a/packages/stream_chat_flutter/test/src/goldens/message_text.png b/packages/stream_chat_flutter/test/src/goldens/message_text.png new file mode 100644 index 00000000..dda158b2 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/message_text.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action_modal_test.dart b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart index 6118e7d2..953434de 100644 --- a/packages/stream_chat_flutter/test/src/message_action_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart @@ -38,6 +38,10 @@ void main() { id: 'user-id', ), ), + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), messageTheme: streamTheme.ownMessageTheme, ), ), @@ -86,6 +90,10 @@ void main() { ), ), messageTheme: streamTheme.ownMessageTheme, + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), ), ), ), @@ -123,6 +131,7 @@ void main() { client: client, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( text: 'test', user: User( @@ -178,6 +187,7 @@ void main() { client: client, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), onReplyTap: (m) { tapped = true; }, @@ -223,6 +233,7 @@ void main() { client: client, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), onThreadReplyTap: (m) { tapped = true; }, @@ -272,6 +283,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( text: 'test', user: User( @@ -320,6 +332,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), editMessageInputBuilder: (context, m) => const Text('test'), message: Message( text: 'test', @@ -371,6 +384,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), onCopyTap: (m) => tapped = true, message: Message( text: 'test', @@ -420,6 +434,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( status: MessageSendingStatus.failed, text: 'test', @@ -469,6 +484,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( status: MessageSendingStatus.failed_update, text: 'test', @@ -516,6 +532,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( id: 'testid', text: 'test', @@ -552,10 +569,8 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); - when(() => client.flagMessage(any())).thenThrow(ApiError( - '{}', - 500, - )); + when(() => client.flagMessage(any())) + .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); final themeData = ThemeData(); final streamTheme = StreamChatThemeData.fromTheme(themeData); @@ -573,6 +588,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( id: 'testid', text: 'test', @@ -609,10 +625,8 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); - when(() => client.flagMessage(any())).thenThrow(ApiError( - '{"code":4}', - 400, - )); + when(() => client.flagMessage(any())) + .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); final themeData = ThemeData(); final streamTheme = StreamChatThemeData.fromTheme(themeData); @@ -630,6 +644,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( id: 'testid', text: 'test', @@ -683,6 +698,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( id: 'testid', text: 'test', @@ -719,10 +735,8 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.deleteMessage(any())).thenThrow(ApiError( - '{}', - 500, - )); + when(() => channel.deleteMessage(any())) + .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); final themeData = ThemeData(); final streamTheme = StreamChatThemeData.fromTheme(themeData); @@ -740,6 +754,7 @@ void main() { channel: channel, child: SizedBox( child: MessageActionsModal( + messageWidget: const Text('test'), message: Message( id: 'testid', text: 'test', 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 63ddb10f..f07f518d 100644 --- a/packages/stream_chat_flutter/test/src/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input_test.dart @@ -53,15 +53,6 @@ void main() { ) ])); - when(() => channelState.typingEvents).thenAnswer((i) => [ - User(id: 'other-user', extraData: {'name': 'demo'}) - ]); - when(() => channelState.typingEventsStream) - .thenAnswer((i) => Stream.value([ - User(id: 'other-user', extraData: {'name': 'demo'}), - User(id: 'other-user', extraData: {'name': 'demo'}), - ])); - await tester.pumpWidget(MaterialApp( home: StreamChat( client: client, @@ -75,7 +66,6 @@ void main() { )); expect(find.byType(TextField), findsOneWidget); - expect(find.byType(StreamSvgIcon), findsNWidgets(8)); expect(find.byKey(const Key('messageInputText')), findsOneWidget); }, ); diff --git a/packages/stream_chat_flutter/test/src/message_reactions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_reactions_modal_test.dart index 6bdb72b0..9452e7e6 100644 --- a/packages/stream_chat_flutter/test/src/message_reactions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_reactions_modal_test.dart @@ -34,6 +34,10 @@ void main() { client: client, streamChatThemeData: streamTheme, child: MessageReactionsModal( + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), message: message, messageTheme: streamTheme.ownMessageTheme, ), @@ -43,28 +47,9 @@ void main() { await tester.pump(const Duration(milliseconds: 1000)); - expect(find.byType(MessageWidget), findsOneWidget); - final messageWidget = - tester.widget(find.byType(MessageWidget)); - expect(messageWidget.message, message); - expect(messageWidget.messageTheme, streamTheme.ownMessageTheme); - expect(messageWidget.attachmentBorderRadiusGeometry, null); - expect(messageWidget.showUserAvatar, DisplayWidget.show); - expect(messageWidget.reverse, false); - expect(messageWidget.attachmentShape, null); - expect(messageWidget.shape, null); - expect(messageWidget.onUserAvatarTap, null); - expect(messageWidget.showReactions, false); - expect(messageWidget.showUsername, false); - expect(messageWidget.showThreadReplyIndicator, false); - expect(messageWidget.showTimestamp, false); - expect(messageWidget.translateUserAvatar, false); - expect(messageWidget.showSendingIndicator, false); - expect(find.byType(ReactionBubble), findsNothing); - //only one avatar (the message one) - expect(find.byType(UserAvatar), findsOneWidget); + expect(find.byType(UserAvatar), findsNothing); }, ); @@ -105,14 +90,14 @@ void main() { client: client, streamChatThemeData: streamTheme, child: MessageReactionsModal( + messageWidget: const Text( + 'test', + key: Key('MessageWidget'), + ), message: message, messageTheme: streamTheme.ownMessageTheme, - showUserAvatar: DisplayWidget.gone, reverse: true, - attachmentBorderRadiusGeometry: BorderRadius.circular(1), - attachmentShape: const RoundedRectangleBorder(), showReactions: false, - messageShape: const RoundedRectangleBorder(), onUserAvatarTap: onUserAvatarTap, ), ), @@ -121,27 +106,7 @@ void main() { await tester.pump(const Duration(milliseconds: 1000)); - expect(find.byType(MessageWidget), findsOneWidget); - final messageWidget = - tester.widget(find.byType(MessageWidget)); - expect(messageWidget.message, message); - expect(messageWidget.messageTheme, streamTheme.ownMessageTheme); - expect(messageWidget.attachmentBorderRadiusGeometry, - BorderRadius.circular(1)); - expect(messageWidget.showUserAvatar, DisplayWidget.gone); - expect(messageWidget.reverse, true); - expect(messageWidget.showReactions, false); - expect(messageWidget.attachmentShape, const RoundedRectangleBorder()); - expect(messageWidget.shape, const RoundedRectangleBorder()); - expect(messageWidget.showReactions, false); - expect(messageWidget.showUsername, false); - expect(messageWidget.showThreadReplyIndicator, false); - expect(messageWidget.showTimestamp, false); - expect(messageWidget.translateUserAvatar, false); - expect(messageWidget.showSendingIndicator, false); - - final userAvatar = tester.widget(find.byType(UserAvatar)); - expect(userAvatar.onTap, onUserAvatarTap); + expect(find.byKey(const Key('MessageWidget')), findsOneWidget); expect(find.byType(ReactionBubble), findsOneWidget); expect(find.byType(UserAvatar), findsOneWidget); diff --git a/packages/stream_chat_flutter/test/src/message_text_test.dart b/packages/stream_chat_flutter/test/src/message_text_test.dart index a8aba86a..501d8c0f 100644 --- a/packages/stream_chat_flutter/test/src/message_text_test.dart +++ b/packages/stream_chat_flutter/test/src/message_text_test.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'mocks.dart'; +import 'simple_frame.dart'; void main() { testWidgets( @@ -51,4 +53,57 @@ void main() { expect(find.byType(MarkdownBody), findsOneWidget); }, ); + + testGoldens( + 'control test', + (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'); + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.fromTheme(themeData); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.user).thenReturn(OwnUser(id: 'user-id')); + when(() => channel.lastMessageAt).thenReturn(lastMessageAt); + when(() => channel.state).thenReturn(channelState); + 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', + }); + + final messageText = '''a message. +with multiple lines +and a list: +- a. okasd +- b lllll + +cool.'''; + + await tester.pumpWidgetBuilder( + materialAppWrapper()(SimpleFrame( + child: StreamChannel( + channel: channel, + child: Scaffold( + body: MessageText( + message: Message( + text: messageText, + ), + messageTheme: streamTheme.otherMessageTheme, + ), + ), + ), + )), + surfaceSize: const Size(500, 500), + ); + await screenMatchesGolden(tester, 'message_text'); + }, + ); } diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index 083c2ff5..28e00e4b 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -2,7 +2,11 @@ import 'package:flutter/material.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -class MockClient extends Mock implements StreamChatClient {} +class MockClient extends Mock implements StreamChatClient { + MockClient() { + when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); + } +} class MockClientState extends Mock implements ClientState {} @@ -17,7 +21,12 @@ class MockChannel extends Mock implements Channel { } } -class MockChannelState extends Mock implements ChannelClientState {} +class MockChannelState extends Mock implements ChannelClientState { + MockChannelState() { + when(() => typingEvents).thenReturn({}); + when(() => typingEventsStream).thenAnswer((_) => Stream.value({})); + } +} class MockNavigatorObserver extends Mock implements NavigatorObserver {} diff --git a/packages/stream_chat_flutter/test/src/thread_header_test.dart b/packages/stream_chat_flutter/test/src/thread_header_test.dart index 578b2ad6..2b649be3 100644 --- a/packages/stream_chat_flutter/test/src/thread_header_test.dart +++ b/packages/stream_chat_flutter/test/src/thread_header_test.dart @@ -45,6 +45,7 @@ void main() { ]); when(() => client.wsConnectionStatusStream) .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); when(() => clientState.totalUnreadCountStream) .thenAnswer((i) => Stream.value(1)); diff --git a/packages/stream_chat_flutter/test/src/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart index cc9af663..67c80b1e 100644 --- a/packages/stream_chat_flutter/test/src/typing_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/typing_indicator_test.dart @@ -53,14 +53,15 @@ void main() { ) ])); - when(() => channelState.typingEvents).thenAnswer((i) => [ - User(id: 'other-user', extraData: {'name': 'demo'}) - ]); + when(() => channelState.typingEvents).thenAnswer((i) => { + User(id: 'other-user', extraData: {'name': 'demo'}): + Event(type: EventType.typingStart), + }); when(() => channelState.typingEventsStream) - .thenAnswer((i) => Stream.value([ - User(id: 'other-user', extraData: {'name': 'demo'}), - User(id: 'other-user', extraData: {'name': 'demo'}), - ])); + .thenAnswer((i) => Stream.value({ + User(id: 'other-user', extraData: {'name': 'demo'}): + Event(type: EventType.typingStart), + })); await tester.pumpWidget(MaterialApp( home: StreamChat( diff --git a/packages/stream_chat_flutter/test/src/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/unread_indicator_test.dart index 29bc0d79..96a6c678 100644 --- a/packages/stream_chat_flutter/test/src/unread_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/unread_indicator_test.dart @@ -83,7 +83,7 @@ void main() { ), )); - expect(find.byType(SizedBox), findsOneWidget); + expect(find.text('0'), findsNothing); }, ); diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 1930e36a..4453659e 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,31 @@ +## 2.0.0-nullsafety.8 + +đŸ›‘ī¸ Breaking Changes from `2.0.0-nullsafety.7` + +- `channelsBloc.queryChannels()`, `ChannelListCore` options param/property is removed in favor of individual + params/properties + - `options.state` -> bool state + - `options.watch` -> bool watch + - `options.presence` -> bool presence +- `usersBloc.queryUsers()`, `UserListCore` options param/property is removed in favor of individual params/properties + - `options.presence` -> bool presence + +## 2.0.0-nullsafety.7 + +* Fixed a bug with connectivity implementation + +## 2.0.0-nullsafety.6 + +* Update llc dependency +* Minor fixes and improvements + +## 2.0.0-nullsafety.5 + +* Update llc dependency +* Minor fixes and improvements +* Performance improvements +* Monitor connection using `connectivity_plus` package + ## 2.0.0-nullsafety.3 * Update llc dependency diff --git a/packages/stream_chat_flutter_core/analysis_options.yaml b/packages/stream_chat_flutter_core/analysis_options.yaml deleted file mode 100644 index f0a87ea5..00000000 --- a/packages/stream_chat_flutter_core/analysis_options.yaml +++ /dev/null @@ -1,150 +0,0 @@ -analyzer: - enable-experiment: - - extension-methods - exclude: - - lib/**/*.g.dart - - example/** - - lib/src/emoji - - lib/**/*.freezed.dart - - test/** - -linter: - rules: - # these rules are documented on and in the same order as - # the Dart Lint rules page to make maintenance easier - # https://github.com/dart-lang/linter/blob/master/example/all.yaml - - always_use_package_imports - - avoid_empty_else - - avoid_relative_lib_imports - - avoid_slow_async_io - - avoid_types_as_parameter_names - - cancel_subscriptions - - close_sinks - - control_flow_in_finally - - empty_statements - - hash_and_equals - - invariant_booleans - - iterable_contains_unrelated_type - - list_remove_unrelated_type - - literal_only_boolean_expressions - - no_adjacent_strings_in_list - - no_duplicate_case_values - - no_logic_in_create_state - - prefer_void_to_null - - test_types_in_equals - - throw_in_finally - - unnecessary_statements - - unrelated_type_equality_checks - - omit_local_variable_types - - use_key_in_widget_constructors - - valid_regexps - - always_declare_return_types - - always_require_non_null_named_parameters - - annotate_overrides - - avoid_bool_literals_in_conditional_expressions - - avoid_catching_errors - - avoid_init_to_null - - avoid_null_checks_in_equality_operators - - avoid_positional_boolean_parameters - - avoid_private_typedef_functions - - avoid_redundant_argument_values - - avoid_return_types_on_setters - - avoid_returning_null_for_void - - avoid_shadowing_type_parameters - - avoid_single_cascade_in_expression_statements - - avoid_unnecessary_containers - - avoid_unused_constructor_parameters - - await_only_futures - - camel_case_extensions - - camel_case_types - - cascade_invocations - - - constant_identifier_names - - curly_braces_in_flow_control_structures - - directives_ordering - - empty_catches - - empty_constructor_bodies - - exhaustive_cases - - file_names - - implementation_imports - - join_return_with_assignment - - leading_newlines_in_multiline_strings - - library_names - - library_prefixes - - lines_longer_than_80_chars - - missing_whitespace_between_adjacent_strings - - non_constant_identifier_names - - null_closures - - one_member_abstracts - - only_throw_errors - - package_api_docs - - package_prefixed_library_names - - parameter_assignments - - prefer_adjacent_string_concatenation - - prefer_asserts_in_initializer_lists - - prefer_asserts_with_message - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - - prefer_constructors_over_static_methods - - prefer_contains - - prefer_equal_for_default_values - - prefer_expression_function_bodies - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - - prefer_int_literals - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - prefer_is_not_empty - - prefer_is_not_operator - - prefer_null_aware_operators - - prefer_single_quotes - - prefer_spread_collections - - prefer_typing_uninitialized_variables - - provide_deprecation_message - - public_member_api_docs - - recursive_getters - - sized_box_for_whitespace - - slash_for_doc_comments - - sort_child_properties_last - - sort_constructors_first - - sort_unnamed_constructors_first - - - type_annotate_public_apis - - type_init_formals - - unnecessary_await_in_return - - unnecessary_brace_in_string_interps - - unnecessary_const - - unnecessary_getters_setters - - unnecessary_lambdas - - unnecessary_new - - unnecessary_null_aware_assignments - - unnecessary_null_in_if_null_operators - - unnecessary_nullable_for_final_variable_declarations - - unnecessary_parenthesis - - unnecessary_raw_strings - - unnecessary_string_escapes - - unnecessary_string_interpolations - - unnecessary_this - - use_is_even_rather_than_modulo - - use_late_for_private_fields_and_variables - - use_rethrow_when_possible - - use_setters_to_change_properties - - use_to_and_as_if_applicable - - package_names - - sort_pub_dependencies - - - cast_nullable_to_non_nullable - - unnecessary_null_checks - - tighten_type_of_initializing_formals - - null_check_on_nullable_type_parameter diff --git a/packages/stream_chat_flutter_core/example/lib/main.dart b/packages/stream_chat_flutter_core/example/lib/main.dart index e7ef400a..e12d2177 100644 --- a/packages/stream_chat_flutter_core/example/lib/main.dart +++ b/packages/stream_chat_flutter_core/example/lib/main.dart @@ -203,9 +203,9 @@ class _MessageScreenState extends State { final channel = StreamChannel.of(context).channel; return Scaffold( appBar: AppBar( - title: StreamBuilder>( - initialData: channel.state?.typingEvents, - stream: channel.state?.typingEventsStream, + title: StreamBuilder>( + initialData: channel.state?.typingEvents.keys, + stream: channel.state?.typingEventsStream.map((it) => it.keys), builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.isNotEmpty) { return Text('${snapshot.data!.first.name} is typing...'); @@ -344,7 +344,7 @@ extension on Channel { String? get name { final _channelName = extraData['name']; if (_channelName != null) { - return _channelName; + 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 new file mode 100644 index 00000000..871a8a1d --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// A more efficient [StreamBuilder] +/// 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 { + /// Creates a new BetterStreamBuilder + const BetterStreamBuilder({ + required this.stream, + required this.initialData, + required this.builder, + this.loadingBuilder, + this.errorBuilder, + this.comparator, + Key? key, + }) : super(key: key); + + /// The stream to listen to + final Stream? stream; + + /// The initial data available + final T initialData; + + /// Comparator used to check if the new data is different than the last one + final bool Function(T?, T?)? comparator; + + /// Builder that builds based on the new snapshot + final Widget Function(BuildContext context, T data) builder; + + /// Builder that builds when the data is null + final Widget Function(BuildContext context)? loadingBuilder; + + /// Builder used when there is an error + final Widget Function(BuildContext context, Object error)? errorBuilder; + + @override + _BetterStreamBuilderState createState() => _BetterStreamBuilderState(); +} + +class _BetterStreamBuilderState extends State> { + T? _lastEvent; + StreamSubscription? _subscription; + Object? _lastError; + + @override + Widget build(BuildContext context) { + if (_lastError != null) { + return widget.errorBuilder!(context, _lastError!); + } + + if (_lastEvent == null) { + return widget.loadingBuilder?.call(context) ?? const Offstage(); + } + return widget.builder(context, _lastEvent ?? widget.initialData); + } + + @override + void initState() { + _lastEvent = widget.initialData; + _subscription = widget.stream?.listen( + _onEvent, + onError: _onError, + ); + super.initState(); + } + + @override + void didUpdateWidget(covariant BetterStreamBuilder oldWidget) { + if (oldWidget.stream != widget.stream) { + _subscription?.cancel(); + _subscription = widget.stream?.listen( + _onEvent, + onError: _onError, + ); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + void _onError(error) { + if (widget.errorBuilder != null && error != _lastError) { + if (mounted) { + setState(() {}); + } + _lastError = error; + } + } + + void _onEvent(T event) { + _lastError = null; + final isEqual = + widget.comparator?.call(_lastEvent, event) ?? event == _lastEvent; + if (!isEqual) { + 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 a3be273d..6dc52647 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 @@ -63,7 +63,11 @@ class ChannelListCore extends StatefulWidget { required this.loadingBuilder, required this.listBuilder, this.filter, - this.options, + this.state = true, + this.watch = true, + this.presence = false, + this.memberLimit, + this.messageLimit, this.sort, this.pagination = const PaginationParams( limit: 25, @@ -94,12 +98,6 @@ class ChannelListCore extends StatefulWidget { /// You can also filter other built-in channel fields. final Filter? filter; - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map? options; - /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can be /// provided. @@ -107,6 +105,21 @@ class ChannelListCore extends StatefulWidget { /// _at or member_count. Direction can be ascending or descending. final List>? sort; + /// If true returns the Channel state + final bool state; + + /// If true listen to changes to this Channel in real time. + final bool watch; + + /// If true you’ll receive user presence updates via the websocket events + final bool presence; + + /// Number of members to fetch in each channel + final int? memberLimit; + + /// Number of messages to fetch in each channel + final int? messageLimit; + /// Pagination parameters /// limit: the number of channels to return (max is 30) /// offset: the offset (max is 1000) @@ -149,18 +162,26 @@ class ChannelListCoreState extends State { Future loadData() => _channelsBloc.queryChannels( filter: widget.filter, sortOptions: widget.sort, + state: widget.state, + watch: widget.watch, + presence: widget.presence, + memberLimit: widget.memberLimit, + messageLimit: widget.messageLimit, paginationParams: widget.pagination, - options: widget.options, ); /// Fetches more channels with updated pagination and updates the widget Future paginateData() => _channelsBloc.queryChannels( filter: widget.filter, sortOptions: widget.sort, + state: widget.state, + watch: widget.watch, + presence: widget.presence, + memberLimit: widget.memberLimit, + messageLimit: widget.messageLimit, paginationParams: widget.pagination.copyWith( offset: _channelsBloc.channels?.length ?? 0, ), - options: widget.options, ); StreamSubscription? _subscription; @@ -200,7 +221,11 @@ class ChannelListCoreState extends State { if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || - widget.options?.toString() != oldWidget.options?.toString() || + widget.state != oldWidget.state || + widget.watch != oldWidget.watch || + widget.presence != oldWidget.presence || + widget.messageLimit != oldWidget.messageLimit || + widget.memberLimit != oldWidget.memberLimit || widget.pagination.toJson().toString() != oldWidget.pagination.toJson().toString()) { loadData(); 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 a3f0b58c..5de9b2ea 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -95,8 +95,13 @@ class ChannelsBlocState extends State Future queryChannels({ Filter? filter, List>? sortOptions, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + bool waitForConnect = true, PaginationParams paginationParams = const PaginationParams(limit: 30), - Map? options, }) async { final client = _streamChatCoreState!.client; @@ -117,7 +122,12 @@ class ChannelsBlocState extends State await for (final channels in client.queryChannels( filter: filter, sort: sortOptions, - options: options, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + waitForConnect: waitForConnect, paginationParams: paginationParams, )) { newChannels = channels; 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 cf11ef5f..2a259e83 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 @@ -1,9 +1,11 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; 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/stream_channel.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart'; @@ -66,7 +68,6 @@ class MessageListCore extends StatefulWidget { required this.emptyBuilder, required this.messageListBuilder, required this.errorWidgetBuilder, - this.showScrollToBottom = true, this.parentMessage, this.messageListController, this.messageFilter, @@ -92,10 +93,6 @@ class MessageListCore extends StatefulWidget { /// event of a connection failure. final ErrorBuilder errorWidgetBuilder; - /// If true will show a scroll to bottom message when there are new messages - /// and the scroll offset is not zero. - final bool showScrollToBottom; - /// If the current message belongs to a `thread`, this property represents the /// first message or the parent of the conversation. final Message? parentMessage; @@ -127,6 +124,10 @@ class MessageListCoreState extends State { .map((threads) => threads[widget.parentMessage!.id]) : _streamChannel!.channel.state?.messagesStream; + final initialData = _isThreadConversation + ? _streamChannel!.channel.state?.threads[widget.parentMessage!.id] + : _streamChannel!.channel.state?.messages; + bool defaultFilter(Message m) { final isMyMessage = m.user?.id == _currentUser?.id; final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true; @@ -134,28 +135,27 @@ class MessageListCoreState extends State { return true; } - return StreamBuilder?>( - stream: messagesStream?.map((messages) => - messages?.where(widget.messageFilter ?? defaultFilter).toList( - growable: false, - )), - builder: (context, snapshot) { - if (snapshot.hasError) { - return widget.errorWidgetBuilder(context, snapshot.error!); - } else if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } else { - final messageList = - snapshot.data?.reversed.toList(growable: false) ?? []; - if (messageList.isEmpty && !_isThreadConversation) { - if (_upToDate) { - return widget.emptyBuilder(context); - } - } else { - _messages = messageList; + return BetterStreamBuilder?>( + initialData: initialData, + comparator: const ListEquality().equals, + stream: messagesStream!.map( + (messages) => + messages?.where(widget.messageFilter ?? defaultFilter).toList( + growable: false, + ), + ), + errorBuilder: widget.errorWidgetBuilder, + loadingBuilder: widget.loadingBuilder, + builder: (context, data) { + final messageList = data?.reversed.toList(growable: false) ?? []; + if (messageList.isEmpty && !_isThreadConversation) { + if (_upToDate) { + return widget.emptyBuilder(context); } - return widget.messageListBuilder(context, _messages); + } else { + _messages = messageList; } + return widget.messageListBuilder(context, _messages); }, ); } 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 a1eb5d68..d354daec 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 @@ -114,15 +114,24 @@ class MessageSearchListCoreState extends State { if (newMessageSearchBloc != _messageSearchBloc) { _messageSearchBloc = newMessageSearchBloc; loadData(); - if (widget.messageSearchListController != null) { - widget.messageSearchListController!.loadData = loadData; - widget.messageSearchListController!.paginateData = paginateData; - } } super.didChangeDependencies(); } + void _setupController() { + if (widget.messageSearchListController != null) { + widget.messageSearchListController!.loadData = loadData; + widget.messageSearchListController!.paginateData = paginateData; + } + } + + @override + void initState() { + super.initState(); + _setupController(); + } + @override Widget build(BuildContext context) => _buildListView(_messageSearchBloc!); @@ -176,6 +185,11 @@ class MessageSearchListCoreState extends State { oldWidget.paginationParams?.toJson().toString()) { loadData(); } + + if (widget.messageSearchListController != + oldWidget.messageSearchListController) { + _setupController(); + } } } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 20304cdd..1dcf9c3c 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -174,7 +174,7 @@ class StreamChannelState extends State { try { final response = await channel.getReplies( parentId, - PaginationParams( + options: PaginationParams( lessThan: message?.id, limit: limit, ), diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart index d3ed4a0b..e80472cd 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -125,12 +125,13 @@ class StreamChatCoreState extends State _isConnectionAvailable = result != ConnectivityResult.none; if (!_isInForeground) return; if (_isConnectionAvailable) { - if (client.wsConnectionStatus == ConnectionStatus.disconnected) { - client.connect(); + if (client.wsConnectionStatus == ConnectionStatus.disconnected && + user != null) { + client.openConnection(); } } else { if (client.wsConnectionStatus == ConnectionStatus.connected) { - client.disconnect(); + client.closeConnection(); } } }); @@ -174,14 +175,14 @@ class StreamChatCoreState extends State _disconnectTimer?.cancel(); } else if (client.wsConnectionStatus == ConnectionStatus.disconnected && _isConnectionAvailable) { - client.connect(); + client.openConnection(); } } void _onBackground() { if (widget.onBackgroundEventReceived == null) { if (client.wsConnectionStatus != ConnectionStatus.disconnected) { - client.disconnect(); + client.closeConnection(); } return; } @@ -190,7 +191,7 @@ class StreamChatCoreState extends State void onTimerComplete() { _eventSubscription?.cancel(); - client.disconnect(); + client.closeConnection(); } _disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete); 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 d34c563c..a45aa904 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 @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter_core/src/users_bloc.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// /// [UserListCore] is a simplified class that allows fetching users while @@ -63,8 +64,8 @@ class UserListCore extends StatefulWidget { required this.listBuilder, Key? key, this.filter, - this.options, this.sort, + this.presence, this.pagination, this.groupAlphabetically = false, this.userListController, @@ -76,7 +77,7 @@ class UserListCore extends StatefulWidget { final UserListController? userListController; /// The builder that will be used in case of error - final Widget Function(Object error) errorBuilder; + final ErrorBuilder errorBuilder; /// The builder that will be used to build the list final Widget Function(BuildContext context, List users) listBuilder; @@ -92,18 +93,15 @@ class UserListCore extends StatefulWidget { /// You can also filter other built-in channel fields. final Filter? filter; - /// Query channels options. - /// - /// state: if true returns the Channel state - /// watch: if true listen to changes to this Channel in real time. - final Map? options; - /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can be /// provided. You can sort based on last_updated, last_message_at, updated_at, /// created_at or member_count. Direction can be ascending or descending. final List? sort; + /// If true you’ll receive user presence updates via the websocket events + final bool? presence; + /// Pagination parameters /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) @@ -130,14 +128,23 @@ class UserListCoreState extends State if (newUsersBloc != _usersBloc) { _usersBloc = newUsersBloc; loadData(); - if (widget.userListController != null) { - widget.userListController!.loadData = loadData; - widget.userListController!.paginateData = paginateData; - } } super.didChangeDependencies(); } + @override + void initState() { + super.initState(); + _setupController(); + } + + void _setupController() { + if (widget.userListController != null) { + widget.userListController!.loadData = loadData; + widget.userListController!.paginateData = paginateData; + } + } + @override Widget build(BuildContext context) => _buildListView(); @@ -173,7 +180,7 @@ class UserListCoreState extends State stream: _buildUserStream(), builder: (context, snapshot) { if (snapshot.hasError) { - return widget.errorBuilder(snapshot.error!); + return widget.errorBuilder(context, snapshot.error!); } if (!snapshot.hasData) { return widget.loadingBuilder(context); @@ -186,22 +193,22 @@ class UserListCoreState extends State }, ); - // ignore: public_member_api_docs + /// Fetches initial users and updates the widget Future loadData() => _usersBloc!.queryUsers( filter: widget.filter, sort: widget.sort, + presence: widget.presence, pagination: widget.pagination, - options: widget.options, ); - // ignore: public_member_api_docs + /// Fetches more users with updated pagination and updates the widget Future paginateData() => _usersBloc!.queryUsers( filter: widget.filter, sort: widget.sort, + presence: widget.presence, pagination: widget.pagination!.copyWith( offset: _usersBloc!.users?.length ?? 0, ), - options: widget.options, ); @override @@ -209,11 +216,15 @@ class UserListCoreState extends State super.didUpdateWidget(oldWidget); if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || - widget.options?.toString() != oldWidget.options?.toString() || + widget.presence != oldWidget.presence || widget.pagination?.toJson().toString() != oldWidget.pagination?.toJson().toString()) { loadData(); } + + if (widget.userListController != oldWidget.userListController) { + _setupController(); + } } } diff --git a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart index 1806ec36..af707762 100644 --- a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart @@ -63,7 +63,7 @@ class UsersBlocState extends State Future queryUsers({ Filter? filter, List? sort, - Map? options, + bool? presence, PaginationParams? pagination, }) async { final client = _streamChatCore.client; @@ -82,7 +82,7 @@ class UsersBlocState extends State final usersResponse = await client.queryUsers( filter: filter, sort: sort, - options: options, + presence: presence, pagination: pagination, ); diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index ef947a0f..8bd41c76 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -3,6 +3,7 @@ library stream_chat_flutter_core; export 'package:connectivity_plus/connectivity_plus.dart'; export 'package:stream_chat/stream_chat.dart'; +export 'src/better_stream_builder.dart'; export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channels_bloc.dart'; export 'src/lazy_load_scroll_view.dart'; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 4292772b..a231caab 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 2.0.0-nullsafety.3 +version: 2.0.0-nullsafety.8 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -16,7 +16,7 @@ dependencies: sdk: flutter meta: ^1.3.0 rxdart: ^0.27.0 - stream_chat: ^2.0.0-nullsafety.2 + stream_chat: ^2.0.0-nullsafety.7 dev_dependencies: fake_async: ^1.2.0 diff --git a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart index 14cbc232..a69f81c9 100644 --- a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart @@ -142,7 +142,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).thenThrow(error); @@ -162,7 +166,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).called(1); }, @@ -191,7 +199,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).thenAnswer((_) => Stream.value(channels)); @@ -211,7 +223,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).called(1); }, @@ -240,7 +256,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).thenAnswer((_) => Stream.value(channels)); @@ -260,7 +280,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).called(1); }, @@ -297,7 +321,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).thenAnswer((_) => Stream.value(channels)); @@ -321,7 +349,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).called(1); @@ -335,7 +367,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: updatedPagination, )).thenAnswer((_) => Stream.value(paginatedChannels)); @@ -355,7 +391,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: updatedPagination, )).called(1); }, @@ -396,7 +436,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).thenAnswer((_) => Stream.value(channels)); @@ -424,7 +468,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: pagination, )).called(1); @@ -436,7 +484,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: updatedPagination, )).thenAnswer((_) => Stream.value(updatedChannels)); @@ -451,7 +503,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: updatedPagination, )).called(1); }, 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 ff373878..3c0286a0 100644 --- a/packages/stream_chat_flutter_core/test/channels_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart @@ -114,7 +114,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( (_) => Stream.fromIterable([offlineChannels, onlineChannels]), @@ -133,7 +137,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); }, @@ -176,7 +184,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenThrow(error); @@ -190,7 +202,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); }, @@ -228,7 +244,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer((_) => Stream.value(channels)); @@ -245,7 +265,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); @@ -257,7 +281,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: paginationParams, )).thenAnswer( (_) => Stream.value(newChannels), @@ -279,7 +307,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: paginationParams, )).called(1); }, @@ -320,7 +352,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: paginationParams, )).thenAnswer((_) => Stream.value(channels)); @@ -336,7 +372,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: paginationParams, )).called(1); @@ -345,7 +385,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: paginationParams, )).thenThrow(error); @@ -359,7 +403,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: paginationParams, )).called(1); }, @@ -404,7 +452,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( (_) => Stream.value(channels), @@ -415,7 +467,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); @@ -476,7 +532,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( (_) => Stream.value(channels), @@ -487,11 +547,16 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); final channelDeletedOrNotificationRemovedEvent = Event( + type: EventType.channelDeleted, channel: EventChannel( cid: channels.first.cid!, updatedAt: DateTime.now(), @@ -557,7 +622,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( (_) => Stream.value(channels), @@ -568,7 +637,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); @@ -648,7 +721,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( (_) => Stream.value(channels), @@ -659,7 +736,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); @@ -735,7 +816,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( (_) => Stream.value(channels), @@ -746,7 +831,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); @@ -813,7 +902,11 @@ void main() { when(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( (_) => Stream.value(channels), @@ -824,7 +917,11 @@ void main() { verify(() => mockClient.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), )).called(1); diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart index e53cd0a8..98e83f9f 100644 --- a/packages/stream_chat_flutter_core/test/message_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -100,6 +100,7 @@ void main() { when(() => mockChannel.state.isUpToDate).thenReturn(true); when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value([])); + when(() => mockChannel.state.messages).thenReturn([]); await tester.pumpWidget( StreamChannel( @@ -133,6 +134,7 @@ void main() { when(() => mockChannel.state.isUpToDate).thenReturn(true); when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value([])); + when(() => mockChannel.state.messages).thenReturn([]); when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); await tester.pumpWidget( @@ -174,6 +176,7 @@ void main() { when(() => mockChannel.state.messages).thenReturn(messages); when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); when(() => mockChannel.initialized).thenAnswer((_) => Future.value(true)); await tester.pumpWidget( @@ -220,6 +223,7 @@ void main() { const error = 'Error! Error! Error!'; when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.error(error)); + when(() => mockChannel.state.messages).thenReturn([]); await tester.pumpWidget( Directionality( @@ -259,6 +263,7 @@ void main() { const messages = []; when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); await tester.pumpWidget( Directionality( @@ -295,7 +300,9 @@ void main() { when(() => mockChannel.state.isUpToDate).thenReturn(false); when(() => mockChannel.initialized).thenAnswer((_) async => true); when(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), membersPagination: any(named: 'membersPagination'), messagesPagination: any(named: 'messagesPagination'), preferOffline: any(named: 'preferOffline'), @@ -305,6 +312,7 @@ void main() { const messages = []; when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); await tester.pumpWidget( Directionality( @@ -349,6 +357,7 @@ void main() { final messages = _generateMessages(); when(() => mockChannel.state.messagesStream) .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messages).thenReturn(messages); await tester.pumpWidget( Directionality( diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart index 6ca24363..73227e7c 100644 --- a/packages/stream_chat_flutter_core/test/mocks.dart +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -4,6 +4,10 @@ import 'package:stream_chat/stream_chat.dart'; class MockLogger extends Mock implements Logger {} class MockClient extends Mock implements StreamChatClient { + MockClient() { + when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); + } + @override final Logger logger = MockLogger(); diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart index b48c02b1..2b32cf2d 100644 --- a/packages/stream_chat_flutter_core/test/stream_channel_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -165,7 +165,9 @@ void main() { when(() => mockChannel.initialized).thenAnswer((_) async => true); final messages = _generateMessages(); when(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: any(named: 'messagesPagination'), membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -183,7 +185,9 @@ void main() { verify(() => mockChannel.initialized).called(1); verify(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: any(named: 'messagesPagination'), membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -228,7 +232,9 @@ void main() { final messages = _generateMessages(); when(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: beforePagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -236,7 +242,9 @@ void main() { )).thenAnswer((_) async => ChannelState(messages: messages)); when(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: afterPagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -259,7 +267,9 @@ void main() { await tester.pumpAndSettle(); verify(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: beforePagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -267,7 +277,9 @@ void main() { )).called(1); verify(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: afterPagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -285,7 +297,9 @@ void main() { ); when(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: updatedBeforePagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -293,7 +307,9 @@ void main() { )).thenAnswer((_) async => ChannelState(messages: messages)); when(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: updatedAfterPagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -303,7 +319,9 @@ void main() { await tester.pumpAndSettle(); verify(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: updatedBeforePagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), @@ -311,7 +329,9 @@ void main() { )).called(1); verify(() => mockChannel.query( - options: any(named: 'options'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), messagesPagination: updatedAfterPagination, membersPagination: any(named: 'membersPagination'), watchersPagination: any(named: 'watchersPagination'), diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart index 936e03f0..f2b1b514 100644 --- a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -53,7 +53,7 @@ void main() { ); testWidgets( - 'didChangeAppLifecycleState should call client.disconnect() and return ' + 'didChangeAppLifecycleState should call client.closeConnection and return ' 'if onBackgroundEventReceived is null and the widget lifestyle changes to ' 'AppLifecycleState.paused', (tester) async { @@ -71,7 +71,7 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - when(() => mockClient.disconnect()).thenAnswer((_) async { + when(() => mockClient.closeConnection()).thenAnswer((_) async { return; }); @@ -81,7 +81,7 @@ void main() { streamChatCoreState.didChangeAppLifecycleState(AppLifecycleState.paused); - verify(() => mockClient.disconnect()).called(1); + verify(() => mockClient.closeConnection()).called(1); }, ); @@ -110,9 +110,9 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); + final event = Event(type: EventType.any); when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(() => mockClient.disconnect()).thenAnswer((_) async { + when(() => mockClient.closeConnection()).thenAnswer((_) async { return; }); @@ -129,7 +129,7 @@ void main() { await Future.delayed(backgroundKeepAlive); - verify(() => mockClient.disconnect()).called(1); + verify(() => mockClient.closeConnection()).called(1); verifyNever(() => mockOnBackgroundEventReceived.call(event)); }); }, @@ -159,7 +159,7 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); + final event = Event(type: EventType.any); when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); final streamChatCoreState = tester.state( @@ -202,10 +202,10 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); + final event = Event(type: EventType.any); when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(() => mockClient.connect()).thenAnswer((_) async => event); - when(mockClient.disconnect).thenAnswer((_) async => null); + when(() => mockClient.openConnection()).thenAnswer((_) async => event); + when(() => mockClient.closeConnection()).thenAnswer((_) async => null); when(() => mockClient.wsConnectionStatus) .thenReturn(ConnectionStatus.disconnected); @@ -221,13 +221,13 @@ void main() { streamChatCoreState .didChangeAppLifecycleState(AppLifecycleState.resumed); - verify(() => mockClient.connect()).called(1); + verify(() => mockClient.openConnection()).called(1); }); }, ); testWidgets( - 'didChangeAppLifecycleState should not call client.connect() ' + 'didChangeAppLifecycleState should not call client.openConnection() ' 'if connection is not available in case the ' 'widget lifestyle changes to AppLifecycleState.resume', (tester) async { @@ -235,6 +235,14 @@ void main() { final mockClient = MockClient(); const streamChatCoreKey = Key('streamChatCore'); const childKey = Key('child'); + + final event = Event(); + when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); + when(() => mockClient.openConnection()).thenAnswer((_) async => event); + when(() => mockClient.closeConnection()).thenAnswer((_) async => null); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, @@ -247,13 +255,6 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); - when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(() => mockClient.connect()).thenAnswer((_) async => event); - when(mockClient.disconnect).thenAnswer((_) async => null); - when(() => mockClient.wsConnectionStatus) - .thenReturn(ConnectionStatus.disconnected); - final streamChatCoreState = tester.state( find.byKey(streamChatCoreKey), ); @@ -266,7 +267,7 @@ void main() { streamChatCoreState .didChangeAppLifecycleState(AppLifecycleState.resumed); - verifyNever(() => mockClient.connect()); + verifyNever(() => mockClient.openConnection()); }); }, ); @@ -323,6 +324,14 @@ void main() { const childKey = Key('child'); final _connectivityController = BehaviorSubject.seeded(ConnectivityResult.none); + + final event = Event(); + when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); + when(() => mockClient.openConnection()).thenAnswer((_) async => event); + when(() => mockClient.closeConnection()).thenAnswer((_) async => null); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, @@ -335,18 +344,11 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); - when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(() => mockClient.connect()).thenAnswer((_) async => event); - when(mockClient.disconnect).thenAnswer((_) async => null); - when(() => mockClient.wsConnectionStatus) - .thenReturn(ConnectionStatus.disconnected); - _connectivityController.add(ConnectivityResult.mobile); await Future.delayed(const Duration(seconds: 1)); - verify(() => mockClient.connect()).called(1); + verify(() => mockClient.openConnection()).called(1); }); }, ); @@ -374,8 +376,8 @@ void main() { final event = Event(); when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(() => mockClient.connect()).thenAnswer((_) async => event); - when(mockClient.disconnect).thenAnswer((_) async => null); + when(() => mockClient.openConnection()).thenAnswer((_) async => event); + when(() => mockClient.closeConnection()).thenAnswer((_) async => null); when(() => mockClient.wsConnectionStatus) .thenReturn(ConnectionStatus.connected); @@ -383,7 +385,7 @@ void main() { await Future.delayed(const Duration(seconds: 1)); - verify(() => mockClient.disconnect()).called(1); + verify(() => mockClient.closeConnection()).called(1); }); }, ); @@ -397,6 +399,14 @@ void main() { const childKey = Key('child'); final _connectivityController = BehaviorSubject.seeded(ConnectivityResult.none); + + final event = Event(); + when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); + when(() => mockClient.openConnection()).thenAnswer((_) async => event); + when(() => mockClient.closeConnection()).thenAnswer((_) async => null); + when(() => mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + final streamChatCore = StreamChatCore( key: streamChatCoreKey, client: mockClient, @@ -409,13 +419,6 @@ void main() { expect(find.byKey(streamChatCoreKey), findsOneWidget); expect(find.byKey(childKey), findsOneWidget); - final event = Event(); - when(() => mockClient.on()).thenAnswer((_) => Stream.value(event)); - when(() => mockClient.connect()).thenAnswer((_) async => event); - when(mockClient.disconnect).thenAnswer((_) async => null); - when(() => mockClient.wsConnectionStatus) - .thenReturn(ConnectionStatus.disconnected); - final streamChatCoreState = tester.state( find.byKey(streamChatCoreKey), ); @@ -429,7 +432,7 @@ void main() { await Future.delayed(const Duration(seconds: 1)); - verifyNever(() => mockClient.disconnect()); + verifyNever(() => mockClient.closeConnection()); }); }, ); diff --git a/packages/stream_chat_flutter_core/test/user_list_core_test.dart b/packages/stream_chat_flutter_core/test/user_list_core_test.dart index 820e479f..95077e5c 100644 --- a/packages/stream_chat_flutter_core/test/user_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/user_list_core_test.dart @@ -43,7 +43,7 @@ void main() { listBuilder: (_, __) => Offstage(), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), ); await tester.pumpWidget(userListCore); @@ -62,7 +62,7 @@ void main() { listBuilder: (_, __) => Offstage(), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), ); final mockClient = MockClient(); @@ -91,7 +91,7 @@ void main() { listBuilder: (_, __) => Offstage(), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), userListController: controller, ); @@ -125,7 +125,8 @@ void main() { listBuilder: (_, __) => Offstage(), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Container(key: errorWidgetKey), + errorBuilder: (BuildContext context, Object error) => + Container(key: errorWidgetKey), ); final mockClient = MockClient(); @@ -134,7 +135,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenThrow(error); @@ -154,7 +155,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); }, @@ -170,7 +171,7 @@ void main() { listBuilder: (_, __) => Offstage(), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Container(key: emptyWidgetKey), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), ); final mockClient = MockClient(); @@ -179,7 +180,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -199,7 +200,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); }, @@ -215,7 +216,7 @@ void main() { listBuilder: (_, __) => Container(key: listWidgetKey), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), ); final mockClient = MockClient(); @@ -224,7 +225,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -244,7 +245,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); }, @@ -273,7 +274,7 @@ void main() { ), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), groupAlphabetically: true, ); @@ -283,7 +284,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -310,7 +311,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); }, @@ -341,7 +342,7 @@ void main() { ), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), pagination: pagination, groupAlphabetically: true, ); @@ -352,7 +353,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -379,7 +380,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); @@ -393,7 +394,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: updatedPagination, )) .thenAnswer( @@ -411,7 +412,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: updatedPagination, )).called(1); }, @@ -446,7 +447,7 @@ void main() { ), loadingBuilder: (BuildContext context) => Offstage(), emptyBuilder: (BuildContext context) => Offstage(), - errorBuilder: (Object error) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), pagination: pagination.copyWith(limit: limit), groupAlphabetically: true, ); @@ -457,7 +458,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -488,7 +489,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); @@ -500,7 +501,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: updatedPagination, )) .thenAnswer((_) async => QueryUsersResponse()..users = updatedUsers); @@ -515,7 +516,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: updatedPagination, )).called(1); }, diff --git a/packages/stream_chat_flutter_core/test/users_bloc_test.dart b/packages/stream_chat_flutter_core/test/users_bloc_test.dart index 2722d08a..b659ca34 100644 --- a/packages/stream_chat_flutter_core/test/users_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/users_bloc_test.dart @@ -72,7 +72,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -86,7 +86,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); }, @@ -121,7 +121,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenThrow(error); @@ -135,7 +135,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); }, @@ -171,7 +171,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -185,7 +185,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); @@ -196,7 +196,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: pagination, )) .thenAnswer( @@ -218,7 +218,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: pagination, )).called(1); }, @@ -254,7 +254,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).thenAnswer((_) async => QueryUsersResponse()..users = users); @@ -268,7 +268,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: any(named: 'pagination'), )).called(1); @@ -280,7 +280,7 @@ void main() { when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: pagination, )).thenThrow(error); @@ -294,7 +294,7 @@ void main() { verify(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), - options: any(named: 'options'), + presence: any(named: 'presence'), pagination: pagination, )).called(1); }, diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index c35459ca..38742b1e 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,13 @@ +## 2.0.0-nullsafety.7 + +* Update llc dependency +* Minor fixes and improvements + +## 2.0.0-nullsafety.5 + +* Update llc dependency +* Minor fixes and improvements + ## 2.0.0-nullsafety.2 * Update llc dependency diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart index 4a169cd2..0cef23b0 100644 --- a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart @@ -29,8 +29,9 @@ class ConnectionEventDao extends DatabaseAccessor return into(connectionEvents).insert( ConnectionEventEntity( id: 1, + type: event.type, lastSyncAt: connectionInfo?.lastSyncAt, - lastEventAt: event.createdAt ?? connectionInfo?.lastEventAt, + lastEventAt: event.createdAt, totalUnreadCount: event.totalUnreadCount ?? connectionInfo?.totalUnreadCount, ownUser: event.me?.toJson() ?? connectionInfo?.ownUser, diff --git a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart index efc8610b..447533e0 100644 --- a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart @@ -51,7 +51,7 @@ class MoorChatDatabase extends _$MoorChatDatabase { // you should bump this number whenever you change or add a table definition. @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( diff --git a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart index 8da71717..fe921e70 100644 --- a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart @@ -4841,6 +4841,9 @@ class ConnectionEventEntity extends DataClass /// event id final int id; + /// event type + final String type; + /// User object of the current user final Map? ownUser; @@ -4857,6 +4860,7 @@ class ConnectionEventEntity extends DataClass final DateTime? lastSyncAt; ConnectionEventEntity( {required this.id, + required this.type, this.ownUser, this.totalUnreadCount, this.unreadChannels, @@ -4869,6 +4873,8 @@ class ConnectionEventEntity extends DataClass return ConnectionEventEntity( id: const IntType() .mapFromDatabaseResponse(data['${effectivePrefix}id'])!, + type: const StringType() + .mapFromDatabaseResponse(data['${effectivePrefix}type'])!, ownUser: $ConnectionEventsTable.$converter0.mapToDart(const StringType() .mapFromDatabaseResponse(data['${effectivePrefix}own_user'])), totalUnreadCount: const IntType().mapFromDatabaseResponse( @@ -4885,6 +4891,7 @@ class ConnectionEventEntity extends DataClass Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); + map['type'] = Variable(type); if (!nullToAbsent || ownUser != null) { final converter = $ConnectionEventsTable.$converter0; map['own_user'] = Variable(converter.mapToSql(ownUser)); @@ -4909,6 +4916,7 @@ class ConnectionEventEntity extends DataClass serializer ??= moorRuntimeOptions.defaultSerializer; return ConnectionEventEntity( id: serializer.fromJson(json['id']), + type: serializer.fromJson(json['type']), ownUser: serializer.fromJson?>(json['ownUser']), totalUnreadCount: serializer.fromJson(json['totalUnreadCount']), unreadChannels: serializer.fromJson(json['unreadChannels']), @@ -4921,6 +4929,7 @@ class ConnectionEventEntity extends DataClass serializer ??= moorRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), + 'type': serializer.toJson(type), 'ownUser': serializer.toJson?>(ownUser), 'totalUnreadCount': serializer.toJson(totalUnreadCount), 'unreadChannels': serializer.toJson(unreadChannels), @@ -4931,6 +4940,7 @@ class ConnectionEventEntity extends DataClass ConnectionEventEntity copyWith( {int? id, + String? type, Value?> ownUser = const Value.absent(), Value totalUnreadCount = const Value.absent(), Value unreadChannels = const Value.absent(), @@ -4938,6 +4948,7 @@ class ConnectionEventEntity extends DataClass Value lastSyncAt = const Value.absent()}) => ConnectionEventEntity( id: id ?? this.id, + type: type ?? this.type, ownUser: ownUser.present ? ownUser.value : this.ownUser, totalUnreadCount: totalUnreadCount.present ? totalUnreadCount.value @@ -4951,6 +4962,7 @@ class ConnectionEventEntity extends DataClass String toString() { return (StringBuffer('ConnectionEventEntity(') ..write('id: $id, ') + ..write('type: $type, ') ..write('ownUser: $ownUser, ') ..write('totalUnreadCount: $totalUnreadCount, ') ..write('unreadChannels: $unreadChannels, ') @@ -4964,16 +4976,19 @@ class ConnectionEventEntity extends DataClass int get hashCode => $mrjf($mrjc( id.hashCode, $mrjc( - ownUser.hashCode, + type.hashCode, $mrjc( - totalUnreadCount.hashCode, - $mrjc(unreadChannels.hashCode, - $mrjc(lastEventAt.hashCode, lastSyncAt.hashCode)))))); + ownUser.hashCode, + $mrjc( + totalUnreadCount.hashCode, + $mrjc(unreadChannels.hashCode, + $mrjc(lastEventAt.hashCode, lastSyncAt.hashCode))))))); @override bool operator ==(Object other) => identical(this, other) || (other is ConnectionEventEntity && other.id == this.id && + other.type == this.type && other.ownUser == this.ownUser && other.totalUnreadCount == this.totalUnreadCount && other.unreadChannels == this.unreadChannels && @@ -4983,6 +4998,7 @@ class ConnectionEventEntity extends DataClass class ConnectionEventsCompanion extends UpdateCompanion { final Value id; + final Value type; final Value?> ownUser; final Value totalUnreadCount; final Value unreadChannels; @@ -4990,6 +5006,7 @@ class ConnectionEventsCompanion extends UpdateCompanion { final Value lastSyncAt; const ConnectionEventsCompanion({ this.id = const Value.absent(), + this.type = const Value.absent(), this.ownUser = const Value.absent(), this.totalUnreadCount = const Value.absent(), this.unreadChannels = const Value.absent(), @@ -4998,14 +5015,16 @@ class ConnectionEventsCompanion extends UpdateCompanion { }); ConnectionEventsCompanion.insert({ this.id = const Value.absent(), + required String type, this.ownUser = const Value.absent(), this.totalUnreadCount = const Value.absent(), this.unreadChannels = const Value.absent(), this.lastEventAt = const Value.absent(), this.lastSyncAt = const Value.absent(), - }); + }) : type = Value(type); static Insertable custom({ Expression? id, + Expression? type, Expression?>? ownUser, Expression? totalUnreadCount, Expression? unreadChannels, @@ -5014,6 +5033,7 @@ class ConnectionEventsCompanion extends UpdateCompanion { }) { return RawValuesInsertable({ if (id != null) 'id': id, + if (type != null) 'type': type, if (ownUser != null) 'own_user': ownUser, if (totalUnreadCount != null) 'total_unread_count': totalUnreadCount, if (unreadChannels != null) 'unread_channels': unreadChannels, @@ -5024,6 +5044,7 @@ class ConnectionEventsCompanion extends UpdateCompanion { ConnectionEventsCompanion copyWith( {Value? id, + Value? type, Value?>? ownUser, Value? totalUnreadCount, Value? unreadChannels, @@ -5031,6 +5052,7 @@ class ConnectionEventsCompanion extends UpdateCompanion { Value? lastSyncAt}) { return ConnectionEventsCompanion( id: id ?? this.id, + type: type ?? this.type, ownUser: ownUser ?? this.ownUser, totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, unreadChannels: unreadChannels ?? this.unreadChannels, @@ -5045,6 +5067,9 @@ class ConnectionEventsCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } + if (type.present) { + map['type'] = Variable(type.value); + } if (ownUser.present) { final converter = $ConnectionEventsTable.$converter0; map['own_user'] = Variable(converter.mapToSql(ownUser.value)); @@ -5068,6 +5093,7 @@ class ConnectionEventsCompanion extends UpdateCompanion { String toString() { return (StringBuffer('ConnectionEventsCompanion(') ..write('id: $id, ') + ..write('type: $type, ') ..write('ownUser: $ownUser, ') ..write('totalUnreadCount: $totalUnreadCount, ') ..write('unreadChannels: $unreadChannels, ') @@ -5094,6 +5120,17 @@ class $ConnectionEventsTable extends ConnectionEvents ); } + final VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedTextColumn type = _constructType(); + GeneratedTextColumn _constructType() { + return GeneratedTextColumn( + 'type', + $tableName, + false, + ); + } + final VerificationMeta _ownUserMeta = const VerificationMeta('ownUser'); @override late final GeneratedTextColumn ownUser = _constructOwnUser(); @@ -5153,8 +5190,15 @@ class $ConnectionEventsTable extends ConnectionEvents } @override - List get $columns => - [id, ownUser, totalUnreadCount, unreadChannels, lastEventAt, lastSyncAt]; + List get $columns => [ + id, + type, + ownUser, + totalUnreadCount, + unreadChannels, + lastEventAt, + lastSyncAt + ]; @override $ConnectionEventsTable get asDslTable => this; @override @@ -5170,6 +5214,12 @@ class $ConnectionEventsTable extends ConnectionEvents if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } + if (data.containsKey('type')) { + context.handle( + _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + } else if (isInserting) { + context.missing(_typeMeta); + } context.handle(_ownUserMeta, const VerificationResult.success()); if (data.containsKey('total_unread_count')) { context.handle( diff --git a/packages/stream_chat_persistence/lib/src/entity/connection_events.dart b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart index cf0819ae..9e91a9cb 100644 --- a/packages/stream_chat_persistence/lib/src/entity/connection_events.dart +++ b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart @@ -8,6 +8,9 @@ class ConnectionEvents extends Table { /// event id IntColumn get id => integer()(); + /// event type + TextColumn get type => text()(); + /// User object of the current user TextColumn get ownUser => text().nullable().map(MapConverter())(); diff --git a/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart index c5cc4198..f0350797 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart @@ -5,6 +5,8 @@ import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; extension ConnectionEventX on ConnectionEventEntity { /// Maps a [ConnectionEventEntity] into [Event] Event toEvent() => Event( + type: type, + createdAt: lastEventAt, me: ownUser != null ? OwnUser.fromJson(ownUser!) : null, totalUnreadCount: totalUnreadCount, unreadChannels: unreadChannels, diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 661f55bb..e5fb8fe6 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 2.0.0-nullsafety.2 +version: 2.0.0-nullsafety.7 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -17,12 +17,12 @@ dependencies: mutex: ^3.0.0 path: ^1.8.0 path_provider: ^2.0.1 - sqlite3_flutter_libs: ^0.4.2 - stream_chat: ^2.0.0-nullsafety.2 + sqlite3_flutter_libs: ^0.5.0 + stream_chat: ^2.0.0-nullsafety.7 dev_dependencies: build_runner: ^2.0.1 mocktail: ^0.1.1 moor_generator: ^4.2.1 pedantic: ^1.11.0 - test: ^1.16.8 + test: ^1.17.7 diff --git a/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart index cb977aa5..3182162b 100644 --- a/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/event_mapper_test.dart @@ -1,21 +1,28 @@ -import 'package:test/test.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; import 'package:stream_chat_persistence/src/mapper/event_mapper.dart'; +import 'package:test/test.dart'; + +import '../utils/date_matcher.dart'; void main() { test('toEvent should map entity into Event', () { + const type = 'dummy.type'; + final now = DateTime.now(); final ownUser = OwnUser(id: 'testUserId'); final entity = ConnectionEventEntity( id: 3, + type: type, ownUser: ownUser.toJson(), totalUnreadCount: 33, unreadChannels: 33, - lastSyncAt: DateTime.now(), - lastEventAt: DateTime.now(), + lastSyncAt: now, + lastEventAt: now, ); final event = entity.toEvent(); expect(event, isA()); + expect(event.type, type); + expect(event.createdAt.toUtc(), isSameDateAs(now.toUtc())); expect(event.me!.id, ownUser.id); expect(event.totalUnreadCount, entity.totalUnreadCount); expect(event.unreadChannels, entity.unreadChannels); diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index 83524870..daaebe61 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -80,7 +80,7 @@ void main() { }); test('getConnectionInfo', () async { - const event = Event(type: 'testEvent'); + final event = Event(); when(() => mockDatabase.connectionEventDao.connectionEvent) .thenAnswer((_) async => event); @@ -101,7 +101,7 @@ void main() { }); test('updateConnectionInfo', () async { - const event = Event(type: 'testEvent'); + final event = Event(); when(() => mockDatabase.connectionEventDao.updateConnectionEvent(event)) .thenAnswer((_) async => 1);