From 3e3fc39db4fdd1c24e72392beea9aee0eff1aac5 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Fri, 26 Jun 2026 19:30:27 -0400 Subject: [PATCH] refactor: migrate messages to chat.sprk --- .env.example | 2 +- .github/workflows/android.yml | 2 +- ios/ci_scripts/ci_post_clone.sh | 2 +- .../data/repositories/aip_scope_policy.dart | 60 ++++++ .../repositories/auth_repository_impl.dart | 53 ++---- lib/src/core/config/app_config.dart | 4 +- lib/src/core/l10n/app_localizations.dart | 12 ++ lib/src/core/l10n/app_localizations_en.dart | 6 + lib/src/core/l10n/intl_en.arb | 10 + .../messages/data/models/message_models.dart | 180 +++++++++++++++++- .../data/repository/messages_repository.dart | 4 +- .../repository/messages_repository_xrpc.dart | 128 +++++++++---- .../providers/conversation_provider.dart | 10 +- .../providers/conversation_state.dart | 4 +- .../providers/conversations._state.dart | 2 +- .../providers/conversations_provider.dart | 2 +- .../messages/ui/pages/messages_page.dart | 27 ++- .../messages/ui/widgets/messages_list.dart | 138 ++++++++++++-- .../utils/chat_message_presentation.dart | 48 +++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- .../auth_repository_impl_test.dart | 81 +++++++- .../data/models/message_models_test.dart | 43 +++++ .../providers/conversation_provider_test.dart | 153 ++++++++++++++- 24 files changed, 828 insertions(+), 149 deletions(-) create mode 100644 lib/src/core/auth/data/repositories/aip_scope_policy.dart create mode 100644 lib/src/features/messages/utils/chat_message_presentation.dart create mode 100644 test/src/core/network/messages/data/models/message_models_test.dart diff --git a/.env.example b/.env.example index c3b996fd..7a0fed94 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ VIDEO_SERVICE_URL=https://video.sprk.so SPRK_APPVIEW_URL=https://api.sprk.so -MESSAGES_SERVICE_URL=https://chat.sprk.so +MESSAGES_SERVICE_URL=https://api.sprk.chat AIP_BASE_URL=https://auth.sprk.so diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 9854bf57..34733b43 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -62,7 +62,7 @@ jobs: run: | echo "VIDEO_SERVICE_URL=https://video.sprk.so" >> .env echo "SPRK_APPVIEW_URL=https://api.sprk.so" >> .env - echo "MESSAGES_SERVICE_URL=https://chat.sprk.so" >> .env + echo "MESSAGES_SERVICE_URL=https://api.sprk.chat" >> .env echo "SHOWCASES_LICENSE_FLUTTER=${{ secrets.SHOWCASES_LICENSE_FLUTTER }}" >> .env echo "SIGNUPS_DISABLED=false" >> .env diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index d7b2d4a0..f206e1b8 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -27,7 +27,7 @@ flutter build ios --config-only --release cat > .env << EOL VIDEO_SERVICE_URL=https://video.sprk.so SPRK_APPVIEW_URL=https://api.sprk.so -MESSAGES_SERVICE_URL=https://chat.sprk.so +MESSAGES_SERVICE_URL=https://api.sprk.chat SHOWCASES_LICENSE_FLUTTER=$SHOWCASES_LICENSE_FLUTTER EOL diff --git a/lib/src/core/auth/data/repositories/aip_scope_policy.dart b/lib/src/core/auth/data/repositories/aip_scope_policy.dart new file mode 100644 index 00000000..da2d10a0 --- /dev/null +++ b/lib/src/core/auth/data/repositories/aip_scope_policy.dart @@ -0,0 +1,60 @@ +import 'package:spark/src/core/config/app_config.dart'; + +class AipScopePolicy { + AipScopePolicy._(this.scopes); + + factory AipScopePolicy.current() { + final sprkAppViewDid = _buildServiceDid( + AppConfig.appViewUrl, + 'sprk_appview', + ); + final bskyAppViewDid = _buildServiceDid( + AppConfig.bskyAppViewUrl, + 'bsky_appview', + ); + + return AipScopePolicy._([ + 'atproto', + 'include:so.sprk.authFullApp?aud=$sprkAppViewDid', + 'include:chat.sprk.authFull?aud=${AppConfig.chatServiceDid}', + 'include:app.bsky.authViewAll?aud=$bskyAppViewDid', + 'include:app.bsky.authCreatePosts?aud=$bskyAppViewDid', + 'include:app.bsky.authDeleteContent?aud=$bskyAppViewDid', + 'blob:*/*', + 'repo:app.bsky.feed.like', + 'repo:app.bsky.feed.repost', + 'repo:app.bsky.graph.follow', + 'rpc:com.atproto.moderation.createReport?aud=*', + ]); + } + + final List scopes; + + String get scope => scopes.join(' '); + + bool registrationScopeMatches(String? storedScope) { + return storedScope == scope; + } + + bool grantedScopesSatisfy(Iterable? storedScopes) { + if (storedScopes == null) { + return false; + } + + final grantedScopes = storedScopes.toSet(); + return scopes.every(grantedScopes.contains); + } + + bool grantedScopeStringSatisfies(String? storedScope) { + if (storedScope == null || storedScope.isEmpty) { + return false; + } + + return grantedScopesSatisfy(storedScope.split(RegExp(r'\s+'))); + } +} + +String _buildServiceDid(String serviceUrl, String serviceId) { + final uri = Uri.parse(serviceUrl); + return 'did:web:${uri.host}#$serviceId'; +} diff --git a/lib/src/core/auth/data/repositories/auth_repository_impl.dart b/lib/src/core/auth/data/repositories/auth_repository_impl.dart index d8a57edd..afef38b5 100644 --- a/lib/src/core/auth/data/repositories/auth_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/auth_repository_impl.dart @@ -11,6 +11,7 @@ import 'package:oauth2/oauth2.dart' as oauth2; import 'package:spark/src/core/auth/data/models/aip_session_response.dart'; import 'package:spark/src/core/auth/data/models/auth_snapshot.dart'; import 'package:spark/src/core/auth/data/models/login_result.dart'; +import 'package:spark/src/core/auth/data/repositories/aip_scope_policy.dart'; import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; import 'package:spark/src/core/config/app_config.dart'; import 'package:spark/src/core/storage/storage.dart'; @@ -29,37 +30,6 @@ const Duration _refreshLeeway = Duration(minutes: 5); const String _randomCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; -String _buildServiceDid(String serviceUrl, String serviceId) { - final uri = Uri.parse(serviceUrl); - return 'did:web:${uri.host}#$serviceId'; -} - -List _buildAipScopes() { - final sprkAppViewDid = _buildServiceDid(AppConfig.appViewUrl, 'sprk_appview'); - final bskyAppViewDid = _buildServiceDid( - AppConfig.bskyAppViewUrl, - 'bsky_appview', - ); - return [ - 'atproto', - 'include:so.sprk.authFullApp?aud=$sprkAppViewDid', - 'include:app.bsky.authViewAll?aud=$bskyAppViewDid', - 'include:app.bsky.authCreatePosts?aud=$bskyAppViewDid', - 'include:app.bsky.authDeleteContent?aud=$bskyAppViewDid', - 'blob:*/*', - 'repo:app.bsky.feed.like', - 'repo:app.bsky.feed.repost', - 'repo:app.bsky.graph.follow', - 'rpc:com.atproto.moderation.createReport?aud=*', - ]; -} - -String _buildAipScope() => _buildAipScopes().join(' '); - -bool _registrationScopeMatches(AipClientRegistration registration) { - return registration.scope == _buildAipScope(); -} - class AuthRepositoryImpl implements AuthRepository { AuthRepositoryImpl({ LocalStorageInterface? secureStorage, @@ -72,6 +42,7 @@ class AuthRepositoryImpl implements AuthRepository { _logger = logger ?? _buildLogger(), _now = now ?? DateTime.now, _fetchSessionInfo = fetchSessionInfo ?? _defaultFetchSessionInfo, + _scopePolicy = AipScopePolicy.current(), _aipBaseUri = _normalizeBaseUri(AppConfig.aipBaseUrl) { _initialize(); } @@ -81,8 +52,8 @@ class AuthRepositoryImpl implements AuthRepository { final SparkLogger _logger; final DateTime Function() _now; final AtprotoSessionFetcher _fetchSessionInfo; + final AipScopePolicy _scopePolicy; final Uri _aipBaseUri; - final List _aipScopes = _buildAipScopes(); final Completer _initCompleter = Completer(); Future? _refreshInFlight; @@ -141,7 +112,9 @@ class AuthRepositoryImpl implements AuthRepository { } final cachedSession = snapshot.pdsSessionCache; - if (cachedSession != null && _isFresh(cachedSession.expiresAtDateTime)) { + if (cachedSession != null && + _isFresh(cachedSession.expiresAtDateTime) && + _scopePolicy.grantedScopeStringSatisfies(cachedSession.scope)) { try { _applyCachedPdsSession(cachedSession); return; @@ -373,7 +346,7 @@ class AuthRepositoryImpl implements AuthRepository { final existing = _snapshot?.aipClientRegistration; if (existing != null && !_registrationNeedsRefresh(existing) && - _registrationScopeMatches(existing)) { + _scopePolicy.registrationScopeMatches(existing.scope)) { return existing; } @@ -387,7 +360,7 @@ class AuthRepositoryImpl implements AuthRepository { 'response_types': ['code'], 'grant_types': ['authorization_code', 'refresh_token'], 'token_endpoint_auth_method': 'client_secret_post', - 'scope': _buildAipScope(), + 'scope': _scopePolicy.scope, 'software_id': _softwareId, 'software_version': _softwareVersion, }), @@ -401,7 +374,7 @@ class AuthRepositoryImpl implements AuthRepository { final registration = _AipClientRegistrationResponse.fromJson( _decodeJsonObject(response.body), - ).toStoredRegistration(scope: _buildAipScope()); + ).toStoredRegistration(scope: _scopePolicy.scope); final previousClientId = existing?.clientId; _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( @@ -479,7 +452,7 @@ class AuthRepositoryImpl implements AuthRepository { var authorizationUri = grant.getAuthorizationUrl( redirectUri, - scopes: _aipScopes, + scopes: _scopePolicy.scopes, state: state, ); @@ -543,7 +516,7 @@ class AuthRepositoryImpl implements AuthRepository { grant.getAuthorizationUrl( Uri.parse(context.redirectUri), - scopes: _aipScopes, + scopes: _scopePolicy.scopes, state: context.state, ); @@ -599,6 +572,10 @@ class AuthRepositoryImpl implements AuthRepository { return null; } + if (!_scopePolicy.grantedScopesSatisfy(credentials.scopes)) { + return null; + } + if (!_credentialsNeedRefresh(credentials)) { return credentials; } diff --git a/lib/src/core/config/app_config.dart b/lib/src/core/config/app_config.dart index 256bee62..d7025b5a 100644 --- a/lib/src/core/config/app_config.dart +++ b/lib/src/core/config/app_config.dart @@ -35,7 +35,7 @@ class AppConfig { /// Base URL for the messages service (chat service). static String get messagesServiceUrl => - _getStringValue('MESSAGES_SERVICE_URL', 'https://chat.sprk.so'); + _getStringValue('MESSAGES_SERVICE_URL', 'https://api.sprk.chat'); /// Base URL for the AIP OAuth server. static String get aipBaseUrl => @@ -43,7 +43,7 @@ class AppConfig { /// Service DID for the chat service (used for service auth). static String get chatServiceDid => - _getStringValue('CHAT_SERVICE_DID', 'did:web:chat.sprk.so'); + _getStringValue('CHAT_SERVICE_DID', 'did:web:api.sprk.chat#sprk_chat'); /// Whether new user registrations are disabled. static bool get signupsDisabled => _getBoolValue('SIGNUPS_DISABLED', false); diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index 32751338..09037da7 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -904,6 +904,18 @@ abstract class AppLocalizations { /// **'Failed to load messages'** String get errorLoadingMessages; + /// Deleted chat message tombstone + /// + /// In en, this message translates to: + /// **'Message deleted'** + String get messageDeleted; + + /// Unsupported chat message tombstone + /// + /// In en, this message translates to: + /// **'Unsupported message'** + String get messageUnsupported; + /// Error loading conversations message /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index 0828f414..6abb1278 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -435,6 +435,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorLoadingMessages => 'Failed to load messages'; + @override + String get messageDeleted => 'Message deleted'; + + @override + String get messageUnsupported => 'Unsupported message'; + @override String get errorLoadingConversations => 'Failed to load conversations'; diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 67a079cf..554d025c 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -705,6 +705,16 @@ "description": "Error loading messages message" }, + "messageDeleted": "Message deleted", + "@messageDeleted": { + "description": "Deleted chat message tombstone" + }, + + "messageUnsupported": "Unsupported message", + "@messageUnsupported": { + "description": "Unsupported chat message tombstone" + }, + "errorLoadingConversations": "Failed to load conversations", "@errorLoadingConversations": { "description": "Error loading conversations message" diff --git a/lib/src/core/network/messages/data/models/message_models.dart b/lib/src/core/network/messages/data/models/message_models.dart index ba7706fd..acd2a0c5 100644 --- a/lib/src/core/network/messages/data/models/message_models.dart +++ b/lib/src/core/network/messages/data/models/message_models.dart @@ -1,5 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; +import 'package:sprk_poptart/chat/sprk/actor/defs.dart'; +import 'package:sprk_poptart/chat/sprk/convo/defs.dart' as chat_defs; part 'message_models.freezed.dart'; part 'message_models.g.dart'; @@ -40,6 +41,14 @@ abstract class SenderView with _$SenderView { factory SenderView.fromJson(Map json) => _$SenderViewFromJson(json); + + factory SenderView.fromChatMessageSender( + chat_defs.MessageViewSender sender, + ) => SenderView(did: sender.did); + + factory SenderView.fromChatReactionSender( + chat_defs.ReactionViewSender sender, + ) => SenderView(did: sender.did); } @freezed @@ -53,6 +62,13 @@ abstract class ReactionView with _$ReactionView { factory ReactionView.fromJson(Map json) => _$ReactionViewFromJson(json); + + factory ReactionView.fromChat(chat_defs.ReactionView reaction) => + ReactionView( + value: reaction.value, + sender: SenderView.fromChatReactionSender(reaction.sender), + createdAt: reaction.createdAt.toUtc().toIso8601String(), + ); } @freezed @@ -70,6 +86,18 @@ abstract class MessageView with _$MessageView { factory MessageView.fromJson(Map json) => _$MessageViewFromJson(json); + + factory MessageView.fromChat(chat_defs.MessageView message) => MessageView( + id: message.id, + rev: message.rev, + text: message.text, + sender: SenderView.fromChatMessageSender(message.sender), + sentAt: message.sentAt.toUtc().toIso8601String(), + reactions: + message.reactions?.map(ReactionView.fromChat).toList(growable: false) ?? + const [], + embed: message.embed?.toString(), + ); } @freezed @@ -84,6 +112,130 @@ abstract class DeletedMessageView with _$DeletedMessageView { factory DeletedMessageView.fromJson(Map json) => _$DeletedMessageViewFromJson(json); + + factory DeletedMessageView.fromChat(chat_defs.DeletedMessageView message) => + DeletedMessageView( + id: message.id, + rev: message.rev, + sender: SenderView.fromChatMessageSender(message.sender), + sentAt: message.sentAt.toUtc().toIso8601String(), + ); +} + +@freezed +abstract class UnsupportedMessageView with _$UnsupportedMessageView { + const factory UnsupportedMessageView({ + required String id, + required String rev, + required SenderView sender, + required String sentAt, + required Map raw, + }) = _UnsupportedMessageView; + const UnsupportedMessageView._(); + + factory UnsupportedMessageView.fromJson(Map json) => + _$UnsupportedMessageViewFromJson(json); + + factory UnsupportedMessageView.fromRaw(Map raw) { + final message = tryFromRaw(raw); + if (message != null) { + return message; + } + + throw FormatException( + 'Unsupported chat message is missing common fields: $raw', + ); + } + + static UnsupportedMessageView? tryFromRaw(Map raw) { + final id = raw['id']; + final rev = raw['rev']; + final sender = raw['sender']; + final sentAt = raw['sentAt']; + final senderDid = sender is Map ? sender['did'] : null; + if (id is! String || + rev is! String || + senderDid is! String || + sentAt is! String) { + return null; + } + + return UnsupportedMessageView( + id: id, + rev: rev, + sender: SenderView(did: senderDid), + sentAt: sentAt, + raw: raw, + ); + } +} + +@freezed +sealed class ChatMessageView with _$ChatMessageView { + const factory ChatMessageView.message({required MessageView data}) = + ChatMessageViewMessage; + const factory ChatMessageView.deleted({required DeletedMessageView data}) = + ChatMessageViewDeleted; + const factory ChatMessageView.unsupported({ + required UnsupportedMessageView data, + }) = ChatMessageViewUnsupported; + const ChatMessageView._(); + + factory ChatMessageView.unsupportedFromRaw(Map raw) => + ChatMessageView.unsupported(data: UnsupportedMessageView.fromRaw(raw)); + + String get id => switch (this) { + ChatMessageViewMessage(:final data) => data.id, + ChatMessageViewDeleted(:final data) => data.id, + ChatMessageViewUnsupported(:final data) => data.id, + }; + + String get rev => switch (this) { + ChatMessageViewMessage(:final data) => data.rev, + ChatMessageViewDeleted(:final data) => data.rev, + ChatMessageViewUnsupported(:final data) => data.rev, + }; + + SenderView get sender => switch (this) { + ChatMessageViewMessage(:final data) => data.sender, + ChatMessageViewDeleted(:final data) => data.sender, + ChatMessageViewUnsupported(:final data) => data.sender, + }; + + String get sentAt => switch (this) { + ChatMessageViewMessage(:final data) => data.sentAt, + ChatMessageViewDeleted(:final data) => data.sentAt, + ChatMessageViewUnsupported(:final data) => data.sentAt, + }; +} + +class ChatMessageViewConverter + implements JsonConverter> { + const ChatMessageViewConverter(); + + @override + ChatMessageView fromJson(Map json) { + final type = json[r'$type'] as String?; + if (type == 'chat.sprk.convo.defs#messageView' || json['text'] is String) { + return ChatMessageView.message(data: MessageView.fromJson(json)); + } + + if (type == 'chat.sprk.convo.defs#deletedMessageView' || + type == null && !json.containsKey('text')) { + return ChatMessageView.deleted(data: DeletedMessageView.fromJson(json)); + } + + return ChatMessageView.unsupported( + data: UnsupportedMessageView.fromRaw(json), + ); + } + + @override + Map toJson(ChatMessageView object) => switch (object) { + ChatMessageViewMessage(:final data) => data.toJson(), + ChatMessageViewDeleted(:final data) => data.toJson(), + ChatMessageViewUnsupported(:final data) => data.raw, + }; } @freezed @@ -92,7 +244,7 @@ abstract class ConvoView with _$ConvoView { required String id, required String rev, required List members, - MessageView? lastMessage, + @ChatMessageViewConverter() ChatMessageView? lastMessage, @Default('accepted') String status, @Default(false) bool muted, @Default(0) int unreadCount, @@ -101,4 +253,28 @@ abstract class ConvoView with _$ConvoView { factory ConvoView.fromJson(Map json) => _$ConvoViewFromJson(json); + + factory ConvoView.fromChat(chat_defs.ConvoView convo) => ConvoView( + id: convo.id, + rev: convo.rev, + members: convo.members, + lastMessage: _chatLastMessageFromChat(convo.lastMessage), + status: convo.status?.toJson() ?? 'accepted', + muted: convo.muted, + unreadCount: convo.unreadCount, + ); +} + +ChatMessageView? _chatLastMessageFromChat( + chat_defs.UConvoViewLastMessage? message, +) { + return switch (message) { + null => null, + chat_defs.UConvoViewLastMessageMessageView(:final data) => + ChatMessageView.message(data: MessageView.fromChat(data)), + chat_defs.UConvoViewLastMessageDeletedMessageView(:final data) => + ChatMessageView.deleted(data: DeletedMessageView.fromChat(data)), + chat_defs.UConvoViewLastMessageUnknown(:final data) => + ChatMessageView.unsupportedFromRaw(data), + }; } diff --git a/lib/src/core/network/messages/data/repository/messages_repository.dart b/lib/src/core/network/messages/data/repository/messages_repository.dart index 57aa7aa1..b3bd89e6 100644 --- a/lib/src/core/network/messages/data/repository/messages_repository.dart +++ b/lib/src/core/network/messages/data/repository/messages_repository.dart @@ -30,7 +30,7 @@ abstract class MessagesRepository { /// [convoId] The conversation ID /// [limit] Optional limit for number of messages to fetch /// [cursor] Optional cursor for pagination (message ID for older messages) - Future<({List messages, String? cursor})> getMessages( + Future<({List messages, String? cursor})> getMessages( String convoId, { int? limit, String? cursor, @@ -40,12 +40,10 @@ abstract class MessagesRepository { /// /// [convoId] The conversation ID /// [text] The message text - /// [facets] Optional facets (currently ignored by server) /// [embed] Optional embed (at:// URI string) Future sendMessage( String convoId, { required String text, - List? facets, String? embed, }); diff --git a/lib/src/core/network/messages/data/repository/messages_repository_xrpc.dart b/lib/src/core/network/messages/data/repository/messages_repository_xrpc.dart index 4e744285..7061d355 100644 --- a/lib/src/core/network/messages/data/repository/messages_repository_xrpc.dart +++ b/lib/src/core/network/messages/data/repository/messages_repository_xrpc.dart @@ -2,11 +2,31 @@ import 'dart:convert'; import 'package:get_it/get_it.dart'; import 'package:http/http.dart' as http; +import 'package:poptart/poptart.dart'; import 'package:spark/src/core/config/app_config.dart'; import 'package:spark/src/core/network/messages/data/models/message_models.dart'; import 'package:spark/src/core/network/messages/data/repository/messages_repository.dart'; import 'package:spark/src/core/network/xrpc/service_auth_helper.dart'; import 'package:spark/src/core/utils/utils.dart'; +import 'package:sprk_poptart/chat/sprk/convo/add_reaction/output.dart' + as add_reaction; +import 'package:sprk_poptart/chat/sprk/convo/defs.dart' as chat_defs; +import 'package:sprk_poptart/chat/sprk/convo/get_convo/output.dart' + as get_convo; +import 'package:sprk_poptart/chat/sprk/convo/get_convo_for_members/output.dart' + as get_convo_for_members; +import 'package:sprk_poptart/chat/sprk/convo/get_messages/output.dart' + as get_messages; +import 'package:sprk_poptart/chat/sprk/convo/get_messages/union_main_messages.dart' + as get_messages_union; +import 'package:sprk_poptart/chat/sprk/convo/list_convos/output.dart' + as list_convos; +import 'package:sprk_poptart/chat/sprk/convo/remove_reaction/output.dart' + as remove_reaction; +import 'package:sprk_poptart/chat/sprk/convo/send_message/input.dart' + as send_message; +import 'package:sprk_poptart/chat/sprk/convo/update_read/output.dart' + as update_read; /// XRPC-based implementation of MessagesRepository using service auth class MessagesRepositoryXrpc implements MessagesRepository { @@ -20,6 +40,15 @@ class MessagesRepositoryXrpc implements MessagesRepository { /// Base URL for the chat service XRPC endpoints String get _baseUrl => AppConfig.messagesServiceUrl; + static const _listConvosNsid = 'chat.sprk.convo.listConvos'; + static const _getConvoNsid = 'chat.sprk.convo.getConvo'; + static const _getConvoForMembersNsid = 'chat.sprk.convo.getConvoForMembers'; + static const _getMessagesNsid = 'chat.sprk.convo.getMessages'; + static const _sendMessageNsid = 'chat.sprk.convo.sendMessage'; + static const _addReactionNsid = 'chat.sprk.convo.addReaction'; + static const _removeReactionNsid = 'chat.sprk.convo.removeReaction'; + static const _updateReadNsid = 'chat.sprk.convo.updateRead'; + /// Makes an XRPC query (GET) request Future> _callQuery( String nsid, @@ -102,38 +131,37 @@ class MessagesRepositoryXrpc implements MessagesRepository { 'readState': ?readState, }; - final data = await _callQuery('so.sprk.chat.listConvos', params); + final data = await _callQuery(_listConvosNsid, params); - final convos = - (data['convos'] as List?) - ?.map((json) => ConvoView.fromJson(json as Map)) - .toList() ?? - []; + final output = list_convos.ConvoListConvosOutput.fromJson(data); + final convos = output.convos + .map(ConvoView.fromChat) + .toList(growable: false); - return (conversations: convos, cursor: data['cursor'] as String?); + return (conversations: convos, cursor: output.cursor); } @override Future getConversation(String convoId) async { - final data = await _callQuery('so.sprk.chat.getConvo', { - 'convoId': convoId, - }); + final data = await _callQuery(_getConvoNsid, {'convoId': convoId}); - return ConvoView.fromJson(data['convo'] as Map); + return ConvoView.fromChat( + get_convo.ConvoGetConvoOutput.fromJson(data).convo, + ); } @override Future getConvoForMembers(List members) async { // Build URL with repeated members parameters // Need to manually construct query string for repeated params - final baseUri = Uri.parse('$_baseUrl/xrpc/so.sprk.chat.getConvoForMembers'); + final baseUri = Uri.parse('$_baseUrl/xrpc/$_getConvoForMembersNsid'); final queryParts = members .map((m) => 'members=${Uri.encodeComponent(m)}') .join('&'); final url = Uri.parse('$baseUri?$queryParts'); final token = await _serviceAuthHelper.getServiceToken( - 'so.sprk.chat.getConvoForMembers', + _getConvoForMembersNsid, ); final response = await http.get( @@ -143,7 +171,11 @@ class MessagesRepositoryXrpc implements MessagesRepository { if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; - return ConvoView.fromJson(data['convo'] as Map); + return ConvoView.fromChat( + get_convo_for_members.ConvoGetConvoForMembersOutput.fromJson( + data, + ).convo, + ); } else { throw Exception( 'XRPC query failed: ${response.statusCode} ${response.body}', @@ -152,7 +184,7 @@ class MessagesRepositoryXrpc implements MessagesRepository { } @override - Future<({List messages, String? cursor})> getMessages( + Future<({List messages, String? cursor})> getMessages( String convoId, { int? limit, String? cursor, @@ -163,22 +195,20 @@ class MessagesRepositoryXrpc implements MessagesRepository { 'cursor': ?cursor, }; - final data = await _callQuery('so.sprk.chat.getMessages', params); + final data = await _callQuery(_getMessagesNsid, params); - final messages = - (data['messages'] as List?) - ?.map((json) => MessageView.fromJson(json as Map)) - .toList() ?? - []; + final output = get_messages.ConvoGetMessagesOutput.fromJson(data); + final messages = output.messages + .map(_chatMessageFromChat) + .toList(growable: false); - return (messages: messages, cursor: data['cursor'] as String?); + return (messages: messages, cursor: output.cursor); } @override Future sendMessage( String convoId, { required String text, - List? facets, String? embed, }) async { // NOTE: We intentionally do NOT retry sendMessage because it's not @@ -186,18 +216,17 @@ class MessagesRepositoryXrpc implements MessagesRepository { // if the first request succeeded but the connection dropped before // the client received the response. // See: https://docs.aws.amazon.com/general/latest/gr/api-retries.html - final body = { - 'convoId': convoId, - 'message': { - 'text': text, - 'facets': ?facets, - 'embed': ?embed, - }, - }; + final body = send_message.ConvoSendMessageInput( + convoId: convoId, + message: chat_defs.MessageInput( + text: text, + embed: embed == null ? null : AtUri.parse(embed), + ), + ).toJson(); - final data = await _callProcedure('so.sprk.chat.sendMessage', body); + final data = await _callProcedure(_sendMessageNsid, body); - return MessageView.fromJson(data); + return MessageView.fromChat(chat_defs.MessageView.fromJson(data)); } @override @@ -212,9 +241,11 @@ class MessagesRepositoryXrpc implements MessagesRepository { 'value': value, }; - final data = await _callProcedure('so.sprk.chat.addReaction', body); + final data = await _callProcedure(_addReactionNsid, body); - return MessageView.fromJson(data); + return MessageView.fromChat( + add_reaction.ConvoAddReactionOutput.fromJson(data).message, + ); } @override @@ -229,17 +260,36 @@ class MessagesRepositoryXrpc implements MessagesRepository { 'value': value, }; - final data = await _callProcedure('so.sprk.chat.removeReaction', body); + final data = await _callProcedure(_removeReactionNsid, body); - return MessageView.fromJson(data); + return MessageView.fromChat( + remove_reaction.ConvoRemoveReactionOutput.fromJson(data).message, + ); } @override Future updateRead(String convoId, String messageId) async { final body = {'convoId': convoId, 'messageId': messageId}; - final data = await _callProcedure('so.sprk.chat.updateRead', body); + final data = await _callProcedure(_updateReadNsid, body); - return ConvoView.fromJson(data['convo'] as Map); + return ConvoView.fromChat( + update_read.ConvoUpdateReadOutput.fromJson(data).convo, + ); } } + +ChatMessageView _chatMessageFromChat( + get_messages_union.UConvoGetMessagesMessages message, +) { + return switch (message) { + get_messages_union.UConvoGetMessagesMessagesMessageView(:final data) => + ChatMessageView.message(data: MessageView.fromChat(data)), + get_messages_union.UConvoGetMessagesMessagesDeletedMessageView( + :final data, + ) => + ChatMessageView.deleted(data: DeletedMessageView.fromChat(data)), + get_messages_union.UConvoGetMessagesMessagesUnknown(:final data) => + ChatMessageView.unsupportedFromRaw(data), + }; +} diff --git a/lib/src/features/messages/providers/conversation_provider.dart b/lib/src/features/messages/providers/conversation_provider.dart index d6df746c..fe0d3ca1 100644 --- a/lib/src/features/messages/providers/conversation_provider.dart +++ b/lib/src/features/messages/providers/conversation_provider.dart @@ -13,10 +13,10 @@ class Conversation extends _$Conversation { ConversationState _mergeMessagesIntoState( ConversationState current, - Iterable incoming, { + Iterable incoming, { String? cursor, }) { - final mergedById = { + final mergedById = { for (final message in current.messages) message.id: message, }; @@ -81,7 +81,11 @@ class Conversation extends _$Conversation { } final latestState = state.value ?? current; - state = AsyncValue.data(_mergeMessagesIntoState(latestState, [sent])); + state = AsyncValue.data( + _mergeMessagesIntoState(latestState, [ + ChatMessageView.message(data: sent), + ]), + ); return sent; } diff --git a/lib/src/features/messages/providers/conversation_state.dart b/lib/src/features/messages/providers/conversation_state.dart index b0d413d8..02ab2809 100644 --- a/lib/src/features/messages/providers/conversation_state.dart +++ b/lib/src/features/messages/providers/conversation_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spark/src/core/network/messages/data/models/message_models.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; +import 'package:sprk_poptart/chat/sprk/actor/defs.dart'; part 'conversation_state.freezed.dart'; @@ -9,7 +9,7 @@ abstract class ConversationState with _$ConversationState { factory ConversationState({ required ConvoView convo, required ProfileViewBasic other, - required List messages, + required List messages, String? cursor, }) = _ConversationState; } diff --git a/lib/src/features/messages/providers/conversations._state.dart b/lib/src/features/messages/providers/conversations._state.dart index 00386890..277aa0db 100644 --- a/lib/src/features/messages/providers/conversations._state.dart +++ b/lib/src/features/messages/providers/conversations._state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spark/src/core/network/messages/data/models/message_models.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; +import 'package:sprk_poptart/chat/sprk/actor/defs.dart'; part 'conversations._state.freezed.dart'; diff --git a/lib/src/features/messages/providers/conversations_provider.dart b/lib/src/features/messages/providers/conversations_provider.dart index 9ce1f187..b6c3e677 100644 --- a/lib/src/features/messages/providers/conversations_provider.dart +++ b/lib/src/features/messages/providers/conversations_provider.dart @@ -4,7 +4,7 @@ import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; import 'package:spark/src/core/network/messages/data/models/message_models.dart'; import 'package:spark/src/core/network/messages/data/repository/messages_repository.dart'; import 'package:spark/src/features/messages/providers/conversations._state.dart'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; +import 'package:sprk_poptart/chat/sprk/actor/defs.dart'; part 'conversations_provider.g.dart'; diff --git a/lib/src/features/messages/ui/pages/messages_page.dart b/lib/src/features/messages/ui/pages/messages_page.dart index 871d6183..8b25b70a 100644 --- a/lib/src/features/messages/ui/pages/messages_page.dart +++ b/lib/src/features/messages/ui/pages/messages_page.dart @@ -8,6 +8,7 @@ import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/messages/providers/conversations_provider.dart'; +import 'package:spark/src/features/messages/utils/chat_message_presentation.dart'; @RoutePage() class MessagesPage extends ConsumerStatefulWidget { @@ -33,20 +34,18 @@ class _MessagesPageState extends ConsumerState { final convo = tuple.$2; final last = convo.lastMessage; final ts = _formatTime(last?.sentAt); - final text = (last?.text ?? '').trim(); - String preview; - if (text.isNotEmpty) { - preview = text; - } else if (last?.embed != null && last!.embed!.isNotEmpty) { - final senderDid = last.sender.did; - final senderProfile = convo.members.firstWhere( - (m) => m.did == senderDid, - orElse: () => profile, - ); - preview = 'Post by @${senderProfile.handle}'; - } else { - preview = ''; - } + final preview = + last?.previewText( + l10n: l10n, + embedPreview: (sender) { + final senderProfile = convo.members.firstWhere( + (m) => m.did == sender.did, + orElse: () => profile, + ); + return 'Post by @${senderProfile.handle}'; + }, + ) ?? + ''; return ChatListItemData( avatarUrl: profile.avatar?.toString(), displayName: profile.displayName ?? profile.handle, diff --git a/lib/src/features/messages/ui/widgets/messages_list.dart b/lib/src/features/messages/ui/widgets/messages_list.dart index f518c9db..4ce95862 100644 --- a/lib/src/features/messages/ui/widgets/messages_list.dart +++ b/lib/src/features/messages/ui/widgets/messages_list.dart @@ -9,13 +9,16 @@ import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/components/molecules/post_tile.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/network/messages/data/models/message_models.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/ui/widgets/image_content.dart'; import 'package:spark/src/core/ui/widgets/video_content.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; import 'package:spark/src/core/utils/share_urls.dart'; import 'package:spark/src/features/messages/providers/message_embed_provider.dart'; +import 'package:spark/src/features/messages/utils/chat_message_presentation.dart'; import 'package:spark/src/features/messages/ui/widgets/message_bubble.dart'; +import 'package:spark/src/features/messages/ui/widgets/sender_avatar.dart'; import 'package:url_launcher/url_launcher.dart'; class MessagesList extends StatelessWidget { @@ -28,7 +31,7 @@ class MessagesList extends StatelessWidget { super.key, }); - final List messages; + final List messages; final ScrollController scrollController; final String? currentUserDid; final String? otherUserHandle; @@ -113,7 +116,7 @@ class _MessageListItem extends ConsumerStatefulWidget { super.key, }); - final MessageView message; + final ChatMessageView message; final bool isCurrentUser; final bool showAvatar; final String? otherUserAvatar; @@ -135,23 +138,24 @@ class _MessageListItemState extends ConsumerState<_MessageListItem> { @override void didUpdateWidget(covariant _MessageListItem oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.message.id != widget.message.id || - oldWidget.message.text != widget.message.text || - oldWidget.message.embed != widget.message.embed) { + if (_shouldRebuildEmbeds(oldWidget.message, widget.message)) { _embedsFuture = _buildEmbeds(); } } Future?> _buildEmbeds() async { - final embedsFromText = await _buildEmbedsFromText(widget.message.text); + final message = widget.message.textMessage; + if (message == null) return null; + + final embedsFromText = await _buildEmbedsFromText(message.text); if (!mounted) { return null; } final combinedEmbeds = []; - if (widget.message.embed != null && widget.message.embed!.isNotEmpty) { - combinedEmbeds.add(_PostEmbedPreview(atUri: widget.message.embed!)); + if (message.embed != null && message.embed!.isNotEmpty) { + combinedEmbeds.add(_PostEmbedPreview(atUri: message.embed!)); } if (embedsFromText != null && embedsFromText.isNotEmpty) { @@ -266,18 +270,112 @@ class _MessageListItemState extends ConsumerState<_MessageListItem> { @override Widget build(BuildContext context) { - return FutureBuilder?>( - future: _embedsFuture, - builder: (context, snapshot) { - return MessageBubble( - message: widget.message, - isCurrentUser: widget.isCurrentUser, - showAvatar: widget.showAvatar, - otherUserAvatar: widget.otherUserAvatar, - otherUserHandle: widget.otherUserHandle, - embeds: snapshot.data, - ); - }, + final message = widget.message.textMessage; + if (message != null) { + return FutureBuilder?>( + future: _embedsFuture, + builder: (context, snapshot) { + return MessageBubble( + message: message, + isCurrentUser: widget.isCurrentUser, + showAvatar: widget.showAvatar, + otherUserAvatar: widget.otherUserAvatar, + otherUserHandle: widget.otherUserHandle, + embeds: snapshot.data, + ); + }, + ); + } + + final systemLabel = widget.message.systemLabel( + AppLocalizations.of(context), + ); + if (systemLabel == null) { + return const SizedBox.shrink(); + } + + return _SystemMessageBubble( + text: systemLabel, + isCurrentUser: widget.isCurrentUser, + showAvatar: widget.showAvatar, + otherUserAvatar: widget.otherUserAvatar, + otherUserHandle: widget.otherUserHandle, + ); + } + + bool _shouldRebuildEmbeds( + ChatMessageView oldMessage, + ChatMessageView message, + ) { + final oldTextMessage = oldMessage.textMessage; + final textMessage = message.textMessage; + if (oldTextMessage == null || textMessage == null) { + return oldMessage.id != message.id || + oldTextMessage != null || + textMessage != null; + } + + return oldTextMessage.id != textMessage.id || + oldTextMessage.text != textMessage.text || + oldTextMessage.embed != textMessage.embed; + } +} + +class _SystemMessageBubble extends StatelessWidget { + const _SystemMessageBubble({ + required this.text, + required this.isCurrentUser, + required this.showAvatar, + required this.otherUserAvatar, + required this.otherUserHandle, + }); + + final String text; + final bool isCurrentUser; + final bool showAvatar; + final String? otherUserAvatar; + final String? otherUserHandle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: isCurrentUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isCurrentUser && showAvatar) ...[ + SenderAvatar( + isCurrentUser: false, + otherUserAvatar: otherUserAvatar, + otherUserHandle: otherUserHandle, + ), + const SizedBox(width: 8), + ] else if (!isCurrentUser) ...[ + const SizedBox(width: 40), + ], + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ], + ), ); } } diff --git a/lib/src/features/messages/utils/chat_message_presentation.dart b/lib/src/features/messages/utils/chat_message_presentation.dart new file mode 100644 index 00000000..de9fc9f4 --- /dev/null +++ b/lib/src/features/messages/utils/chat_message_presentation.dart @@ -0,0 +1,48 @@ +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/network/messages/data/models/message_models.dart'; + +extension ChatMessagePresentation on ChatMessageView { + MessageView? get textMessage => switch (this) { + ChatMessageViewMessage(:final data) => data, + ChatMessageViewDeleted() || ChatMessageViewUnsupported() => null, + }; + + String previewText({ + required AppLocalizations l10n, + required String Function(SenderView sender) embedPreview, + }) { + return switch (this) { + ChatMessageViewMessage(:final data) => _textMessagePreview( + data, + embedPreview: embedPreview, + ), + ChatMessageViewDeleted() => l10n.messageDeleted, + ChatMessageViewUnsupported() => l10n.messageUnsupported, + }; + } + + String? systemLabel(AppLocalizations l10n) { + return switch (this) { + ChatMessageViewMessage() => null, + ChatMessageViewDeleted() => l10n.messageDeleted, + ChatMessageViewUnsupported() => l10n.messageUnsupported, + }; + } +} + +String _textMessagePreview( + MessageView message, { + required String Function(SenderView sender) embedPreview, +}) { + final text = message.text.trim(); + if (text.isNotEmpty) { + return text; + } + + final embed = message.embed; + if (embed != null && embed.isNotEmpty) { + return embedPreview(message.sender); + } + + return ''; +} diff --git a/pubspec.lock b/pubspec.lock index a25f227e..8f3fa17b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1614,10 +1614,10 @@ packages: dependency: "direct main" description: name: sprk_poptart - sha256: b2f5f70832f37f1345b80aa900b3416bdc30d788fe21df242b0062dd59ed588b + sha256: "142f8e397749520aff31cf1f3f4fcfd65465372d0fe5a17040a165fed2deff11" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" sqflite: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f1582e20..40141e57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,7 +24,7 @@ dependencies: plyr_poptart: ^0.1.0 poptart: ^0.1.1 poptart_lex: ^0.1.0 - sprk_poptart: ^0.1.1 + sprk_poptart: ^0.1.2 cached_network_image: ^3.4.1 camera: ^0.12.0+1 collection: ^1.19.1 diff --git a/test/src/core/auth/data/repositories/auth_repository_impl_test.dart b/test/src/core/auth/data/repositories/auth_repository_impl_test.dart index 9cf15668..038b16a3 100644 --- a/test/src/core/auth/data/repositories/auth_repository_impl_test.dart +++ b/test/src/core/auth/data/repositories/auth_repository_impl_test.dart @@ -8,6 +8,7 @@ import 'package:oauth2/oauth2.dart' as oauth2; import 'package:spark/src/core/auth/data/models/account.dart'; import 'package:spark/src/core/auth/data/models/aip_session_response.dart'; import 'package:spark/src/core/auth/data/models/auth_snapshot.dart'; +import 'package:spark/src/core/auth/data/repositories/aip_scope_policy.dart'; import 'package:spark/src/core/auth/data/repositories/auth_repository_impl.dart'; import 'package:spark/src/core/storage/preferences/local_storage_interface.dart'; import 'package:spark/src/core/storage/preferences/storage_constants.dart'; @@ -49,6 +50,55 @@ void main() { expect(networkCalls, 0); }); + test('startup with stale cached PDS scope refreshes via AIP', () async { + final storage = _InMemoryStorage(); + await _storeSnapshot( + storage, + AuthSnapshot( + aipClientRegistration: const AipClientRegistration( + clientId: 'client-1', + ), + aipGrant: AipGrant( + credentialsJson: oauth2.Credentials( + 'aip-access', + expiration: DateTime.utc(2030, 1, 1), + scopes: _currentAipScopes, + ).toJson(), + ), + pdsSessionCache: _pdsSessionCache( + accessToken: _pdsJwt(clientId: 'client-1'), + expiresAt: DateTime.utc(2030, 1, 1), + scopes: const ['atproto'], + ), + ), + ); + + var sessionCalls = 0; + final client = MockClient((request) async { + if (request.url.path == '/api/atprotocol/session') { + sessionCalls += 1; + expect(request.headers['authorization'], 'Bearer aip-access'); + return http.Response( + json.encode(_sessionResponseBody(_pdsJwt(clientId: 'client-1'))), + 200, + ); + } + + return http.Response('unexpected request', 500); + }); + + final repository = AuthRepositoryImpl( + secureStorage: storage, + httpClient: client, + logger: SparkLogger(name: 'AuthRepositoryTest'), + ); + + await repository.initializationComplete; + + expect(repository.isAuthenticated, isTrue); + expect(sessionCalls, 1); + }); + test( 'startup clears legacy account payload and requires re-login', () async { @@ -100,6 +150,7 @@ void main() { credentialsJson: oauth2.Credentials( 'aip-access', expiration: DateTime.utc(2030, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -151,6 +202,7 @@ void main() { credentialsJson: oauth2.Credentials( 'aip-access', expiration: DateTime.utc(2030, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -197,6 +249,7 @@ void main() { refreshToken: 'refresh-1', tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), expiration: DateTime.utc(2020, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -275,6 +328,7 @@ void main() { refreshToken: 'refresh-1', tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), expiration: DateTime.utc(2020, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -326,6 +380,7 @@ void main() { refreshToken: 'refresh-1', tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), expiration: DateTime.utc(2020, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -407,6 +462,7 @@ void main() { refreshToken: 'refresh-1', tokenEndpoint: Uri.parse('https://auth.sprk.so/oauth/token'), expiration: DateTime.utc(2020, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -493,6 +549,7 @@ void main() { credentialsJson: oauth2.Credentials( 'aip-access', expiration: DateTime.utc(2030, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -551,6 +608,7 @@ void main() { credentialsJson: oauth2.Credentials( 'aip-access', expiration: DateTime.utc(2030, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -603,6 +661,7 @@ void main() { credentialsJson: oauth2.Credentials( 'aip-access', expiration: DateTime.utc(2030, 1, 1), + scopes: _currentAipScopes, ).toJson(), ), pdsSessionCache: _pdsSessionCache( @@ -663,7 +722,7 @@ void main() { ); expect( registrationBody['scope'] as String, - 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', + 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:chat.sprk.authFull?aud=did:web:api.sprk.chat#sprk_chat include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', ); return http.Response( json.encode({ @@ -711,7 +770,7 @@ void main() { expect(authUri.queryParameters['state'], isNotEmpty); expect( authUri.queryParameters['scope'], - 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', + 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:chat.sprk.authFull?aud=did:web:api.sprk.chat#sprk_chat include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', ); final callbackUrl = Uri.parse(_redirectUri) @@ -842,7 +901,7 @@ void main() { json.decode(request.body) as Map; expect( registrationBody['scope'] as String, - 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', + 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:chat.sprk.authFull?aud=did:web:api.sprk.chat#sprk_chat include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', ); return http.Response( json.encode({ @@ -869,7 +928,7 @@ void main() { expect(registrationCalls, 1); expect( authUri.queryParameters['scope'], - 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', + 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:chat.sprk.authFull?aud=did:web:api.sprk.chat#sprk_chat include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', ); final savedSnapshot = AuthSnapshot.fromJsonString( @@ -878,7 +937,7 @@ void main() { expect(savedSnapshot.aipClientRegistration?.clientId, 'client-2'); expect( savedSnapshot.aipClientRegistration?.scope, - 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', + 'atproto include:so.sprk.authFullApp?aud=did:web:api.sprk.so#sprk_appview include:chat.sprk.authFull?aud=did:web:api.sprk.chat#sprk_chat include:app.bsky.authViewAll?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authCreatePosts?aud=did:web:api.bsky.app#bsky_appview include:app.bsky.authDeleteContent?aud=did:web:api.bsky.app#bsky_appview blob:*/* repo:app.bsky.feed.like repo:app.bsky.feed.repost repo:app.bsky.graph.follow rpc:com.atproto.moderation.createReport?aud=*', ); }, ); @@ -927,6 +986,8 @@ void main() { const String _redirectUri = 'sprk://oauth-callback'; +List get _currentAipScopes => AipScopePolicy.current().scopes; + Future _storeSnapshot( _InMemoryStorage storage, AuthSnapshot snapshot, @@ -937,23 +998,27 @@ Future _storeSnapshot( PdsSessionCache _pdsSessionCache({ required String accessToken, required DateTime expiresAt, + List? scopes, }) { return buildPdsSessionCacheFromAipResponse( AipAtprotocolSessionResponse.fromJson( - _sessionResponseBody(accessToken) + _sessionResponseBody(accessToken, scopes: scopes) ..['expires_at'] = expiresAt.millisecondsSinceEpoch ~/ 1000, ), clientId: 'https://auth.sprk.so/oauth-client-metadata.json', ); } -Map _sessionResponseBody(String accessToken) { +Map _sessionResponseBody( + String accessToken, { + List? scopes, +}) { return { 'did': 'did:plc:test', 'handle': 'test.sprk.so', 'access_token': accessToken, 'token_type': 'dpop', - 'scopes': ['atproto'], + 'scopes': scopes ?? _currentAipScopes, 'pds_endpoint': 'https://pds.sprk.so', 'dpop_key': 'did:key:test', 'dpop_jwk': { diff --git a/test/src/core/network/messages/data/models/message_models_test.dart b/test/src/core/network/messages/data/models/message_models_test.dart new file mode 100644 index 00000000..ddfbd63d --- /dev/null +++ b/test/src/core/network/messages/data/models/message_models_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/network/messages/data/models/message_models.dart'; + +void main() { + group('UnsupportedMessageView', () { + test('returns null when unknown raw message lacks common fields', () { + expect( + UnsupportedMessageView.tryFromRaw(const { + r'$type': 'chat.sprk.convo.defs#futureMessageView', + 'payload': {'kind': 'future'}, + }), + isNull, + ); + }); + + test('preserves unknown raw message with common fields', () { + final message = UnsupportedMessageView.tryFromRaw(const { + r'$type': 'chat.sprk.convo.defs#futureMessageView', + 'id': 'message-1', + 'rev': 'rev-1', + 'sender': {'did': 'did:plc:test'}, + 'sentAt': '2026-04-11T10:00:00.000Z', + }); + + expect(message, isNotNull); + expect(message!.id, 'message-1'); + expect(message.sender.did, 'did:plc:test'); + }); + + test( + 'ChatMessageView unsupported factory fails for unrepresentable raw', + () { + expect( + () => ChatMessageView.unsupportedFromRaw(const { + r'$type': 'chat.sprk.convo.defs#futureMessageView', + 'payload': {'kind': 'future'}, + }), + throwsFormatException, + ); + }, + ); + }); +} diff --git a/test/src/features/messages/providers/conversation_provider_test.dart b/test/src/features/messages/providers/conversation_provider_test.dart index d640eeda..7bd37a48 100644 --- a/test/src/features/messages/providers/conversation_provider_test.dart +++ b/test/src/features/messages/providers/conversation_provider_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:collection'; -import 'package:sprk_poptart/so/sprk/actor/defs.dart'; +import 'package:sprk_poptart/chat/sprk/actor/defs.dart'; import 'package:poptart/poptart.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -57,14 +57,17 @@ void main() { id: 'convo-1', rev: 'rev-1', members: [me, other], - lastMessage: initialMessage, + lastMessage: ChatMessageView.message(data: initialMessage), ); messagesRepository.getMessagesResponses.add(( - messages: [initialMessage], + messages: [ChatMessageView.message(data: initialMessage)], cursor: 'cursor-1', )); messagesRepository.getMessagesResponses.add(( - messages: [initialMessage, inboundMessage], + messages: [ + ChatMessageView.message(data: initialMessage), + ChatMessageView.message(data: inboundMessage), + ], cursor: 'cursor-1', )); @@ -96,6 +99,135 @@ void main() { ]); expect(state.convo.lastMessage?.id, '3'); }); + + test('preserves deleted messages when polling updates arrive', () async { + final me = ProfileViewBasic(did: 'did:me', handle: 'me.test'); + final other = ProfileViewBasic(did: 'did:other', handle: 'other.test'); + final initialMessage = _message( + id: '1', + text: 'initial', + senderDid: other.did, + sentAt: '2026-04-11T10:00:00.000Z', + ); + final deletedMessage = DeletedMessageView( + id: '2', + rev: 'rev-2', + sender: SenderView(did: other.did), + sentAt: '2026-04-11T10:00:01.000Z', + ); + + messagesRepository.conversation = ConvoView( + id: 'convo-1', + rev: 'rev-1', + members: [me, other], + lastMessage: ChatMessageView.message(data: initialMessage), + ); + messagesRepository.getMessagesResponses.add(( + messages: [ChatMessageView.message(data: initialMessage)], + cursor: 'cursor-1', + )); + messagesRepository.getMessagesResponses.add(( + messages: [ + ChatMessageView.message(data: initialMessage), + ChatMessageView.deleted(data: deletedMessage), + ], + cursor: 'cursor-1', + )); + + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(conversationProvider('convo-1').future); + final notifier = container.read(conversationProvider('convo-1').notifier); + + await notifier.checkForNewMessages(); + await notifier.markReadUpToLatest(); + + final state = container.read(conversationProvider('convo-1')).value; + expect(state, isNotNull); + expect(state!.messages.map((message) => message.id).toList(), ['1', '2']); + expect( + state.messages.last, + isA().having( + (message) => message.id, + 'id', + '2', + ), + ); + expect(messagesRepository.readUpdates, [('convo-1', '2')]); + }); + + test( + 'preserves unsupported messages when polling updates arrive', + () async { + final me = ProfileViewBasic(did: 'did:me', handle: 'me.test'); + final other = ProfileViewBasic(did: 'did:other', handle: 'other.test'); + final initialMessage = _message( + id: '1', + text: 'initial', + senderDid: other.did, + sentAt: '2026-04-11T10:00:00.000Z', + ); + final unsupportedMessage = UnsupportedMessageView( + id: '2', + rev: 'rev-2', + sender: SenderView(did: other.did), + sentAt: '2026-04-11T10:00:01.000Z', + raw: const { + r'$type': 'chat.sprk.convo.defs#futureMessageView', + 'id': '2', + 'rev': 'rev-2', + 'sender': {'did': 'did:other'}, + 'sentAt': '2026-04-11T10:00:01.000Z', + }, + ); + + messagesRepository.conversation = ConvoView( + id: 'convo-1', + rev: 'rev-1', + members: [me, other], + lastMessage: ChatMessageView.message(data: initialMessage), + ); + messagesRepository.getMessagesResponses.add(( + messages: [ChatMessageView.message(data: initialMessage)], + cursor: 'cursor-1', + )); + messagesRepository.getMessagesResponses.add(( + messages: [ + ChatMessageView.message(data: initialMessage), + ChatMessageView.unsupported(data: unsupportedMessage), + ], + cursor: 'cursor-1', + )); + + final container = ProviderContainer(); + addTearDown(container.dispose); + + await container.read(conversationProvider('convo-1').future); + final notifier = container.read( + conversationProvider('convo-1').notifier, + ); + + await notifier.checkForNewMessages(); + await notifier.markReadUpToLatest(); + + final state = container.read(conversationProvider('convo-1')).value; + expect(state, isNotNull); + expect(state!.messages.map((message) => message.id).toList(), [ + '1', + '2', + ]); + expect( + state.messages.last, + isA().having( + (message) => message.id, + 'id', + '2', + ), + ); + expect(messagesRepository.readUpdates, [('convo-1', '2')]); + }, + ); }); } @@ -117,9 +249,10 @@ MessageView _message({ class _FakeMessagesRepository implements MessagesRepository { late ConvoView conversation; - final Queue<({List messages, String? cursor})> + final Queue<({List messages, String? cursor})> getMessagesResponses = - Queue<({List messages, String? cursor})>(); + Queue<({List messages, String? cursor})>(); + final List<(String convoId, String messageId)> readUpdates = []; Future Function({ required String convoId, required String text, @@ -145,7 +278,7 @@ class _FakeMessagesRepository implements MessagesRepository { } @override - Future<({List messages, String? cursor})> getMessages( + Future<({List messages, String? cursor})> getMessages( String convoId, { int? limit, String? cursor, @@ -176,7 +309,6 @@ class _FakeMessagesRepository implements MessagesRepository { Future sendMessage( String convoId, { required String text, - List? facets, String? embed, }) { final handler = sendMessageHandler; @@ -187,8 +319,9 @@ class _FakeMessagesRepository implements MessagesRepository { } @override - Future updateRead(String convoId, String messageId) { - throw UnimplementedError(); + Future updateRead(String convoId, String messageId) async { + readUpdates.add((convoId, messageId)); + return conversation; } } -- 2.51.2