[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: chat state and transport

+510 -159
+14
lib/src/core/network/xrpc/service_auth_helper.dart
··· 16 16 17 17 // Cache tokens by NSID to avoid redundant requests 18 18 final Map<String, ({String token, DateTime expiry})> _tokenCache = {}; 19 + String? _cachedDid; 19 20 20 21 /// Service DID for the chat service (audience for JWT) 21 22 String get serviceDid => AppConfig.chatServiceDid; ··· 23 24 /// Gets a service auth token for the specified NSID (lexicon method) 24 25 /// Tokens are cached and reused if not expired 25 26 Future<String> getServiceToken(String nsid) async { 27 + final currentDid = _authRepository.did; 28 + 29 + if (_cachedDid != currentDid) { 30 + _logger.d( 31 + 'Auth identity changed for service auth cache ' 32 + '($_cachedDid -> $currentDid), clearing cached tokens', 33 + ); 34 + _tokenCache.clear(); 35 + _cachedDid = currentDid; 36 + } 37 + 26 38 // Check cache first 27 39 final cached = _tokenCache[nsid]; 28 40 if (cached != null && ··· 59 71 token: token, 60 72 expiry: DateTime.fromMillisecondsSinceEpoch(exp * 1000), 61 73 ); 74 + _cachedDid = currentDid; 62 75 63 76 return token; 64 77 } catch (e) { ··· 70 83 /// Clears the token cache 71 84 void clearCache() { 72 85 _tokenCache.clear(); 86 + _cachedDid = null; 73 87 } 74 88 75 89 /// Clears a specific token from cache
+47 -12
lib/src/features/messages/providers/conversation_provider.dart
··· 11 11 class Conversation extends _$Conversation { 12 12 String? _oldestCursor; 13 13 14 + ConversationState _mergeMessagesIntoState( 15 + ConversationState current, 16 + Iterable<MessageView> incoming, { 17 + String? cursor, 18 + }) { 19 + final mergedById = <String, MessageView>{ 20 + for (final message in current.messages) message.id: message, 21 + }; 22 + 23 + for (final message in incoming) { 24 + mergedById[message.id] = message; 25 + } 26 + 27 + final mergedMessages = mergedById.values.toList(); 28 + final lastMessage = mergedMessages.isNotEmpty 29 + ? mergedMessages.last 30 + : current.convo.lastMessage; 31 + 32 + return current.copyWith( 33 + messages: mergedMessages, 34 + cursor: cursor ?? current.cursor, 35 + convo: current.convo.copyWith(lastMessage: lastMessage), 36 + ); 37 + } 38 + 14 39 @override 15 40 FutureOr<ConversationState> build(String convoId) async { 16 41 final repo = GetIt.I<MessagesRepository>(); ··· 50 75 if (current == null) throw StateError('Conversation not loaded'); 51 76 52 77 final sent = await repo.sendMessage(convoId, text: text, embed: embed); 53 - state = AsyncValue.data( 54 - current.copyWith(messages: [...current.messages, sent]), 55 - ); 78 + 79 + if (!ref.mounted) { 80 + return sent; 81 + } 82 + 83 + final latestState = state.value ?? current; 84 + state = AsyncValue.data(_mergeMessagesIntoState(latestState, [sent])); 85 + 56 86 return sent; 57 87 } 58 88 ··· 63 93 64 94 // Fetch latest batch (no cursor -> newest) 65 95 final latest = await repo.getMessages(current.convo.id, limit: 50); 66 - // Merge by id 67 - final existingIds = {for (final m in current.messages) m.id}; 68 - final newOnes = latest.messages 69 - .where((m) => !existingIds.contains(m.id)) 70 - .toList(); 71 - if (newOnes.isNotEmpty) { 72 - state = AsyncValue.data( 73 - current.copyWith(messages: [...current.messages, ...newOnes]), 74 - ); 96 + 97 + if (!ref.mounted) { 98 + return; 99 + } 100 + 101 + final latestState = state.value ?? current; 102 + final mergedState = _mergeMessagesIntoState( 103 + latestState, 104 + latest.messages, 105 + cursor: latest.cursor ?? latestState.cursor, 106 + ); 107 + 108 + if (mergedState != latestState) { 109 + state = AsyncValue.data(mergedState); 75 110 } 76 111 } 77 112
+213 -147
lib/src/features/messages/ui/widgets/messages_list.dart
··· 19 19 import 'package:spark/src/features/messages/ui/widgets/message_bubble.dart'; 20 20 import 'package:url_launcher/url_launcher.dart'; 21 21 22 + enum _ResolvedLinkKind { none, image, video } 23 + 24 + class _MessageLinkClassifier { 25 + static final Map<String, _ResolvedLinkKind> _cache = {}; 26 + 27 + static Future<_ResolvedLinkKind> classify(String url) async { 28 + final cached = _cache[url]; 29 + if (cached != null) { 30 + return cached; 31 + } 32 + 33 + final resolved = await _resolve(url); 34 + _cache[url] = resolved; 35 + return resolved; 36 + } 37 + 38 + static Future<_ResolvedLinkKind> _resolve(String url) async { 39 + final contentType = await _fetchContentType(url); 40 + if (contentType == null) { 41 + return _ResolvedLinkKind.none; 42 + } 43 + 44 + if (_isImage(contentType)) { 45 + return _ResolvedLinkKind.image; 46 + } 47 + 48 + if (_isVideo(contentType)) { 49 + return _ResolvedLinkKind.video; 50 + } 51 + 52 + return _ResolvedLinkKind.none; 53 + } 54 + 55 + static Future<String?> _fetchContentType(String url) async { 56 + try { 57 + final uri = Uri.parse(url); 58 + final headResponse = await http.head(uri); 59 + final headContentType = headResponse.headers['content-type']; 60 + if (headResponse.statusCode == 200 && headContentType != null) { 61 + return headContentType; 62 + } 63 + 64 + if (headResponse.statusCode != 405 && headResponse.statusCode < 500) { 65 + return headContentType; 66 + } 67 + 68 + final getResponse = await http.get(uri); 69 + if (getResponse.statusCode != 200) { 70 + return null; 71 + } 72 + 73 + return getResponse.headers['content-type']; 74 + } catch (_) { 75 + return null; 76 + } 77 + } 78 + 79 + static bool _isImage(String contentType) { 80 + return contentType.startsWith('image/'); 81 + } 82 + 83 + static bool _isVideo(String contentType) { 84 + return contentType.startsWith('video/'); 85 + } 86 + } 87 + 22 88 class MessagesList extends StatelessWidget { 23 89 const MessagesList({ 24 90 required this.messages, ··· 35 101 final String? otherUserHandle; 36 102 final String? otherUserAvatar; 37 103 38 - Future<void> logLinkMetadata(List<String> links) async { 39 - if (links.isEmpty) return; 40 - for (final link in links) { 41 - try { 42 - final metadata = await AnyLinkPreview.getMetadata(link: link); 43 - GetIt.I<LogService>() 44 - .getLogger('MessagesList') 45 - .i('Link metadata for $link: $metadata'); 46 - } catch (e) { 47 - GetIt.I<LogService>() 48 - .getLogger('MessagesList') 49 - .e('Failed to get metadata for link $link: $e'); 50 - } 104 + @override 105 + Widget build(BuildContext context) { 106 + if (messages.isEmpty) { 107 + return Center( 108 + child: Column( 109 + mainAxisAlignment: MainAxisAlignment.center, 110 + children: [ 111 + Icon( 112 + FluentIcons.chat_24_regular, 113 + size: 64, 114 + color: Theme.of(context).colorScheme.onSurface, 115 + ), 116 + const SizedBox(height: 16), 117 + Text( 118 + 'No messages yet', 119 + style: TextStyle( 120 + fontSize: 18, 121 + color: Theme.of(context).colorScheme.onSurface, 122 + fontWeight: FontWeight.w500, 123 + ), 124 + ), 125 + const SizedBox(height: 8), 126 + Text( 127 + 'Send a message to start the conversation', 128 + style: TextStyle( 129 + fontSize: 14, 130 + color: Theme.of(context).colorScheme.onSurface, 131 + ), 132 + ), 133 + ], 134 + ), 135 + ); 51 136 } 137 + 138 + return ListView.builder( 139 + controller: scrollController, 140 + padding: const EdgeInsets.all(16), 141 + cacheExtent: 1000, 142 + reverse: true, 143 + itemCount: messages.length, 144 + itemBuilder: (context, index) { 145 + final messageIndex = messages.length - 1 - index; 146 + final message = messages[messageIndex]; 147 + final isCurrentUser = 148 + currentUserDid != null && message.sender.did == currentUserDid; 149 + final hasNewerMessage = messageIndex + 1 < messages.length; 150 + final showAvatar = 151 + !isCurrentUser && 152 + (!hasNewerMessage || 153 + messages[messageIndex + 1].sender.did != message.sender.did); 154 + 155 + return Column( 156 + children: [ 157 + _MessageListItem( 158 + key: ValueKey(message.id), 159 + message: message, 160 + isCurrentUser: isCurrentUser, 161 + showAvatar: showAvatar, 162 + otherUserAvatar: otherUserAvatar, 163 + otherUserHandle: otherUserHandle, 164 + ), 165 + const SizedBox(height: 8), 166 + ], 167 + ); 168 + }, 169 + ); 52 170 } 171 + } 53 172 54 - Future<bool> validateImage(String imageUrl) async { 55 - http.Response res; 56 - try { 57 - res = await http.get(Uri.parse(imageUrl)); 58 - } catch (e) { 59 - return false; 60 - } 61 - if (res.statusCode != 200) return false; 62 - final Map<String, dynamic> data = res.headers; 63 - return checkIfImage(data['content-type'] as String); 173 + class _MessageListItem extends StatefulWidget { 174 + const _MessageListItem({ 175 + required this.message, 176 + required this.isCurrentUser, 177 + required this.showAvatar, 178 + required this.otherUserAvatar, 179 + required this.otherUserHandle, 180 + super.key, 181 + }); 182 + 183 + final MessageView message; 184 + final bool isCurrentUser; 185 + final bool showAvatar; 186 + final String? otherUserAvatar; 187 + final String? otherUserHandle; 188 + 189 + @override 190 + State<_MessageListItem> createState() => _MessageListItemState(); 191 + } 192 + 193 + class _MessageListItemState extends State<_MessageListItem> { 194 + late Future<List<Widget>?> _embedsFuture; 195 + 196 + @override 197 + void initState() { 198 + super.initState(); 199 + _embedsFuture = _buildEmbeds(); 64 200 } 65 201 66 - bool checkIfImage(String param) { 67 - if (param == 'image/jpeg' || 68 - param == 'image/png' || 69 - param == 'image/gif' || 70 - param == 'image/webp' || 71 - param == 'image/bmp' || 72 - param == 'image/svg+xml') { 73 - return true; 202 + @override 203 + void didUpdateWidget(covariant _MessageListItem oldWidget) { 204 + super.didUpdateWidget(oldWidget); 205 + if (oldWidget.message.id != widget.message.id || 206 + oldWidget.message.text != widget.message.text || 207 + oldWidget.message.embed != widget.message.embed) { 208 + _embedsFuture = _buildEmbeds(); 74 209 } 75 - return false; 76 210 } 77 211 78 - Future<bool> validateVideo(String videoUrl) async { 79 - http.Response res; 80 - try { 81 - res = await http.get(Uri.parse(videoUrl)); 82 - } catch (e) { 83 - return false; 212 + Future<List<Widget>?> _buildEmbeds() async { 213 + final embedsFromText = await _buildEmbedsFromText(widget.message.text); 214 + final combinedEmbeds = <Widget>[]; 215 + 216 + if (widget.message.embed != null && widget.message.embed!.isNotEmpty) { 217 + combinedEmbeds.add(_PostEmbedPreview(atUri: widget.message.embed!)); 84 218 } 85 - if (res.statusCode != 200) return false; 86 - final Map<String, dynamic> data = res.headers; 87 - return checkIfVideo(data['content-type'] as String); 88 - } 89 219 90 - bool checkIfVideo(String param) { 91 - if (param == 'video/mp4' || 92 - param == 'video/webm' || 93 - param == 'video/ogg' || 94 - param == 'video/avi' || 95 - param == 'video/mov' || 96 - param == 'video/quicktime') { 97 - return true; 220 + if (embedsFromText != null && embedsFromText.isNotEmpty) { 221 + combinedEmbeds.addAll(embedsFromText); 98 222 } 99 - return false; 100 - } 101 223 102 - /// Checks if a URL is a sprk.so watch URL and extracts the post URI 103 - String? extractSprkPostUri(String url) { 104 - return extractSparkPostUri(url); 224 + return combinedEmbeds.isEmpty ? null : combinedEmbeds; 105 225 } 106 226 107 - Future<List<Widget>?> validateAndCreateEmbedsFromText(String text) async { 227 + Future<List<Widget>?> _buildEmbedsFromText(String text) async { 108 228 List<Widget>? embeds; 109 229 110 - // Extract links from text 111 230 final urlRegex = RegExp( 112 231 r'https?://(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*|www\.[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*', 113 232 caseSensitive: false, 114 233 ); 115 234 final links = urlRegex.allMatches(text).map((m) => m.group(0)!).toList(); 116 - if (links.isEmpty) return embeds; 235 + if (links.isEmpty) { 236 + return embeds; 237 + } 117 238 118 239 final images = <String>[]; 119 240 final videos = <String>[]; 120 241 final sprkPosts = <String>[]; 242 + final filteredLinks = <String>[]; 121 243 122 - final linksToRemove = <String>[]; 123 244 for (final link in links) { 124 - if (link.isEmpty) continue; 125 - if (Uri.tryParse(link)?.hasScheme != true) continue; 245 + if (link.isEmpty) { 246 + continue; 247 + } 248 + 249 + final uri = Uri.tryParse(link); 250 + if (uri?.hasScheme != true) { 251 + continue; 252 + } 126 253 127 - final sprkPostUri = extractSprkPostUri(link); 254 + final sprkPostUri = extractSparkPostUri(link); 128 255 if (sprkPostUri != null) { 129 256 sprkPosts.add(sprkPostUri); 130 - linksToRemove.add(link); 131 - } else if (await validateImage(link)) { 132 - images.add(link); 133 - linksToRemove.add(link); 134 - } else if (await validateVideo(link)) { 135 - videos.add(link); 136 - linksToRemove.add(link); 257 + continue; 258 + } 259 + 260 + switch (await _MessageLinkClassifier.classify(link)) { 261 + case _ResolvedLinkKind.image: 262 + images.add(link); 263 + case _ResolvedLinkKind.video: 264 + videos.add(link); 265 + case _ResolvedLinkKind.none: 266 + filteredLinks.add(link); 137 267 } 138 268 } 139 - // Remove reclassified links 140 - final filteredLinks = links 141 - .where((l) => !linksToRemove.contains(l)) 142 - .toList(); 143 269 144 270 if (images.isNotEmpty) { 145 271 embeds ??= []; ··· 151 277 ), 152 278 ); 153 279 } 280 + 154 281 if (videos.isNotEmpty) { 155 282 embeds ??= []; 156 283 for (final videoUrl in videos) { ··· 162 289 ); 163 290 } 164 291 } 292 + 165 293 if (sprkPosts.isNotEmpty) { 166 294 embeds ??= []; 167 295 for (final postUri in sprkPosts) { 168 296 embeds.add(_SprkPostThumbnail(postUri: postUri)); 169 297 } 170 298 } 299 + 171 300 if (filteredLinks.isNotEmpty) { 172 301 embeds ??= []; 173 302 GetIt.I<LogService>() ··· 188 317 ), 189 318 ); 190 319 } 320 + 191 321 return embeds; 192 322 } 193 323 194 324 @override 195 325 Widget build(BuildContext context) { 196 - if (messages.isEmpty) { 197 - return Center( 198 - child: Column( 199 - mainAxisAlignment: MainAxisAlignment.center, 200 - children: [ 201 - Icon( 202 - FluentIcons.chat_24_regular, 203 - size: 64, 204 - color: Theme.of(context).colorScheme.onSurface, 205 - ), 206 - const SizedBox(height: 16), 207 - Text( 208 - 'No messages yet', 209 - style: TextStyle( 210 - fontSize: 18, 211 - color: Theme.of(context).colorScheme.onSurface, 212 - fontWeight: FontWeight.w500, 213 - ), 214 - ), 215 - const SizedBox(height: 8), 216 - Text( 217 - 'Send a message to start the conversation', 218 - style: TextStyle( 219 - fontSize: 14, 220 - color: Theme.of(context).colorScheme.onSurface, 221 - ), 222 - ), 223 - ], 224 - ), 225 - ); 226 - } 227 - 228 - return ListView.builder( 229 - controller: scrollController, 230 - padding: const EdgeInsets.all(16), 231 - cacheExtent: 1000, 232 - reverse: true, 233 - itemCount: messages.length, 234 - itemBuilder: (context, index) { 235 - final messageIndex = messages.length - 1 - index; 236 - final message = messages[messageIndex]; 237 - final isCurrentUser = 238 - currentUserDid != null && message.sender.did == currentUserDid; 239 - final hasNewerMessage = messageIndex + 1 < messages.length; 240 - final showAvatar = 241 - !isCurrentUser && 242 - (!hasNewerMessage || 243 - messages[messageIndex + 1].sender.did != message.sender.did); 244 - 245 - return Column( 246 - children: [ 247 - FutureBuilder<List<Widget>?>( 248 - future: validateAndCreateEmbedsFromText(message.text), 249 - builder: (context, snapshot) { 250 - final combinedEmbeds = <Widget>[]; 251 - if (message.embed != null && message.embed!.isNotEmpty) { 252 - combinedEmbeds.add(_PostEmbedPreview(atUri: message.embed!)); 253 - } 254 - if (snapshot.hasData && (snapshot.data?.isNotEmpty ?? false)) { 255 - combinedEmbeds.addAll(snapshot.data!); 256 - } 257 - 258 - return MessageBubble( 259 - message: message, 260 - isCurrentUser: isCurrentUser, 261 - showAvatar: showAvatar, 262 - otherUserAvatar: otherUserAvatar, 263 - otherUserHandle: otherUserHandle, 264 - embeds: combinedEmbeds, 265 - ); 266 - }, 267 - ), 268 - const SizedBox(height: 8), 269 - ], 326 + return FutureBuilder<List<Widget>?>( 327 + future: _embedsFuture, 328 + builder: (context, snapshot) { 329 + return MessageBubble( 330 + message: widget.message, 331 + isCurrentUser: widget.isCurrentUser, 332 + showAvatar: widget.showAvatar, 333 + otherUserAvatar: widget.otherUserAvatar, 334 + otherUserHandle: widget.otherUserHandle, 335 + embeds: snapshot.data, 270 336 ); 271 337 }, 272 338 );
+236
test/src/features/messages/providers/conversation_provider_test.dart
··· 1 + import 'dart:async'; 2 + import 'dart:collection'; 3 + 4 + import 'package:atproto/atproto.dart'; 5 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:get_it/get_it.dart'; 8 + import 'package:spark/src/core/auth/data/models/login_result.dart'; 9 + import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; 10 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 11 + import 'package:spark/src/core/network/messages/data/models/message_models.dart'; 12 + import 'package:spark/src/core/network/messages/data/repository/messages_repository.dart'; 13 + import 'package:spark/src/features/messages/providers/conversation_provider.dart'; 14 + 15 + void main() { 16 + group('Conversation provider', () { 17 + late _FakeMessagesRepository messagesRepository; 18 + late _FakeAuthRepository authRepository; 19 + 20 + setUp(() async { 21 + await GetIt.I.reset(); 22 + messagesRepository = _FakeMessagesRepository(); 23 + authRepository = _FakeAuthRepository(); 24 + GetIt.I 25 + ..registerSingleton<MessagesRepository>(messagesRepository) 26 + ..registerSingleton<AuthRepository>(authRepository); 27 + }); 28 + 29 + tearDown(() async { 30 + await GetIt.I.reset(); 31 + }); 32 + 33 + test('merges send and polling updates without dropping messages', () async { 34 + final me = ProfileViewBasic(did: 'did:me', handle: 'me.test'); 35 + final other = ProfileViewBasic(did: 'did:other', handle: 'other.test'); 36 + final initialMessage = _message( 37 + id: '1', 38 + text: 'initial', 39 + senderDid: other.did, 40 + sentAt: '2026-04-11T10:00:00.000Z', 41 + ); 42 + final inboundMessage = _message( 43 + id: '2', 44 + text: 'incoming', 45 + senderDid: other.did, 46 + sentAt: '2026-04-11T10:00:01.000Z', 47 + ); 48 + final sentMessage = _message( 49 + id: '3', 50 + text: 'outgoing', 51 + senderDid: me.did, 52 + sentAt: '2026-04-11T10:00:02.000Z', 53 + ); 54 + 55 + messagesRepository.conversation = ConvoView( 56 + id: 'convo-1', 57 + rev: 'rev-1', 58 + members: [me, other], 59 + lastMessage: initialMessage, 60 + ); 61 + messagesRepository.getMessagesResponses.add(( 62 + messages: [initialMessage], 63 + cursor: 'cursor-1', 64 + )); 65 + messagesRepository.getMessagesResponses.add(( 66 + messages: [initialMessage, inboundMessage], 67 + cursor: 'cursor-1', 68 + )); 69 + 70 + final sendCompleter = Completer<MessageView>(); 71 + messagesRepository.sendMessageHandler = 72 + ({required String convoId, required String text, String? embed}) { 73 + expect(convoId, 'convo-1'); 74 + expect(text, 'hello'); 75 + return sendCompleter.future; 76 + }; 77 + 78 + final container = ProviderContainer(); 79 + addTearDown(container.dispose); 80 + 81 + await container.read(conversationProvider('convo-1').future); 82 + final notifier = container.read(conversationProvider('convo-1').notifier); 83 + 84 + final sendFuture = notifier.sendMessage('convo-1', 'hello'); 85 + await notifier.checkForNewMessages(); 86 + sendCompleter.complete(sentMessage); 87 + await sendFuture; 88 + 89 + final state = container.read(conversationProvider('convo-1')).value; 90 + expect(state, isNotNull); 91 + expect(state!.messages.map((message) => message.id).toList(), [ 92 + '1', 93 + '2', 94 + '3', 95 + ]); 96 + expect(state.convo.lastMessage?.id, '3'); 97 + }); 98 + }); 99 + } 100 + 101 + MessageView _message({ 102 + required String id, 103 + required String text, 104 + required String senderDid, 105 + required String sentAt, 106 + }) { 107 + return MessageView( 108 + id: id, 109 + rev: 'rev-$id', 110 + text: text, 111 + sender: SenderView(did: senderDid), 112 + sentAt: sentAt, 113 + reactions: const [], 114 + ); 115 + } 116 + 117 + class _FakeMessagesRepository implements MessagesRepository { 118 + late ConvoView conversation; 119 + final Queue<({List<MessageView> messages, String? cursor})> 120 + getMessagesResponses = 121 + Queue<({List<MessageView> messages, String? cursor})>(); 122 + Future<MessageView> Function({ 123 + required String convoId, 124 + required String text, 125 + String? embed, 126 + })? 127 + sendMessageHandler; 128 + 129 + @override 130 + Future<MessageView> addReaction( 131 + String convoId, 132 + String messageId, 133 + String value, 134 + ) { 135 + throw UnimplementedError(); 136 + } 137 + 138 + @override 139 + Future<ConvoView> getConversation(String convoId) async => conversation; 140 + 141 + @override 142 + Future<ConvoView> getConvoForMembers(List<String> members) { 143 + throw UnimplementedError(); 144 + } 145 + 146 + @override 147 + Future<({List<MessageView> messages, String? cursor})> getMessages( 148 + String convoId, { 149 + int? limit, 150 + String? cursor, 151 + }) async { 152 + expect(getMessagesResponses, isNotEmpty); 153 + return getMessagesResponses.removeFirst(); 154 + } 155 + 156 + @override 157 + Future<({List<ConvoView> conversations, String? cursor})> listConversations({ 158 + int? limit, 159 + String? cursor, 160 + String? readState, 161 + }) { 162 + throw UnimplementedError(); 163 + } 164 + 165 + @override 166 + Future<MessageView> removeReaction( 167 + String convoId, 168 + String messageId, 169 + String value, 170 + ) { 171 + throw UnimplementedError(); 172 + } 173 + 174 + @override 175 + Future<MessageView> sendMessage( 176 + String convoId, { 177 + required String text, 178 + List<dynamic>? facets, 179 + String? embed, 180 + }) { 181 + final handler = sendMessageHandler; 182 + if (handler == null) { 183 + throw StateError('sendMessageHandler not configured'); 184 + } 185 + return handler(convoId: convoId, text: text, embed: embed); 186 + } 187 + 188 + @override 189 + Future<ConvoView> updateRead(String convoId, String messageId) { 190 + throw UnimplementedError(); 191 + } 192 + } 193 + 194 + class _FakeAuthRepository implements AuthRepository { 195 + @override 196 + ATProto? get atproto => null; 197 + 198 + @override 199 + String? get did => 'did:me'; 200 + 201 + @override 202 + String? get handle => 'me.test'; 203 + 204 + @override 205 + Future<void> get initializationComplete async {} 206 + 207 + @override 208 + bool get isAuthenticated => true; 209 + 210 + @override 211 + String? get pdsEndpoint => null; 212 + 213 + @override 214 + Future<LoginResult> completeOAuth(String callbackUrl) async { 215 + throw UnimplementedError(); 216 + } 217 + 218 + @override 219 + Future<String> initiateOAuth(String handle) async { 220 + throw UnimplementedError(); 221 + } 222 + 223 + @override 224 + Future<String> initiateOAuthWithService(String service) async { 225 + throw UnimplementedError(); 226 + } 227 + 228 + @override 229 + Future<void> logout() async {} 230 + 231 + @override 232 + Future<bool> refreshToken() async => false; 233 + 234 + @override 235 + Future<bool> validateSession() async => true; 236 + }