diff --git a/docs/BUGS.md b/docs/BUGS.md index 102c77a..7740bac 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -2,3 +2,5 @@ title: Bugs updated: 2026-03-18 --- + +Logging out crashes the app. It requires a restart to fix. diff --git a/docs/designs/menu.html b/docs/designs/menu.html new file mode 100644 index 0000000..d045b10 --- /dev/null +++ b/docs/designs/menu.html @@ -0,0 +1,1003 @@ + + + + + + Home - Lazurite + + + + + + + + +
+ + + + + + + +
+ +

Home

+ +
+ +
+ +
+
+
JD
+
+ +
+
+
+
+ + + + +
+ +
+
+ + +
+ + +
+ + +
+
+
AS
+ +
+ +
+ Just launched my new project! 🚀 Check out the demo at example.com. + Special thanks to @bob.bsky.social for the help! + #buildinpublic #dev +
+ +
+
[Project Screenshot]
+
+
My Awesome Project
+
example.com
+
+
+ +
+ + + + +
+
+ + +
+
+
BJ
+ +
+ +
+ Working on some exciting features for the next release! Here's a sneak peek of what's coming: improved + search, better notifications, and dark mode support. #bluesky + #atproto +
+ +
+ + + + +
+
+ + +
+
+
CW
+ +
+ +
+ The @atproto team has been doing amazing work! Read their latest + blog post about federation: atproto.com/blog/federation + +

+ + Key highlights: • Self-hosting guides • PDS (Personal Data Server) setup • Relay infrastructure + +

+ + #atprotocol + #decentralized + #socialweb +
+ +
+ + + + +
+
+ + +
+
+
DM
+ +
+ +
+ Beautiful sunset from my hike today! 🌅 #nature + #photography #hiking +
+ +
+
[Sunset Photo]
+
+ +
+ + + + +
+
+
+ + + +
+ + + + diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md index f269236..ec08ccd 100644 --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -2,15 +2,15 @@ ## M12 — Direct Messages -- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination +- [x] Conversation list screen via `chat.bsky.convo.listConvos` with pagination - [x] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` - [x] Primary / Requests tab filtering on conversation list -- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination +- [x] Message thread screen via `chat.bsky.convo.getMessages` with pagination - [x] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` -- [ ] Chat bubble layout — current user right-aligned, others left-aligned +- [x] Chat bubble layout — current user right-aligned, others left-aligned - [x] Send messages via `chat.bsky.convo.sendMessage` - [x] New conversation via `chat.bsky.convo.getConvoForMembers` -- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread +- [x] Long-press to copy individual messages, overflow menu "Copy All" for full thread - [x] Mute / unmute conversations - [x] Mark conversation as read via `chat.bsky.convo.updateRead` diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0f7381a..037fa3a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Flutter (1.0.0) - image_picker_ios (0.0.1): - Flutter @@ -38,6 +40,7 @@ PODS: - Flutter DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -51,6 +54,8 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter image_picker_ios: @@ -67,6 +72,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/workmanager/ios" SPEC CHECKSUMS: + connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba diff --git a/lib/core/network/xrpc_client_factory.dart b/lib/core/network/xrpc_client_factory.dart index b6cbbfe..302d79c 100644 --- a/lib/core/network/xrpc_client_factory.dart +++ b/lib/core/network/xrpc_client_factory.dart @@ -2,6 +2,7 @@ import 'package:atproto/atproto.dart' as atp; import 'package:atproto_core/atproto_core.dart' as atp_core; import 'package:atproto_oauth/atproto_oauth.dart' as atp_oauth; import 'package:bluesky/bluesky.dart'; +import 'package:bluesky/bluesky_chat.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; /// Creates a Bluesky client from authentication tokens. @@ -43,6 +44,37 @@ Bluesky? createBlueskyClient(AuthTokens? tokens) { return Bluesky.fromSession(session, service: tokens.service); } +BlueskyChat? createBlueSkyChatClient(AuthTokens? tokens) { + if (tokens == null) return null; + + if (tokens.usesOAuth) { + if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) { + return null; + } + + final oauthSession = atp_core.restoreOAuthSession( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken!, + dPoPNonce: tokens.dpopNonce, + publicKey: tokens.dpopPublicKey!, + privateKey: tokens.dpopPrivateKey!, + ); + + return BlueskyChat.fromOAuthSession(oauthSession); + } + + if (tokens.refreshToken == null) return null; + + final session = atp_core.Session( + did: tokens.did, + handle: tokens.handle, + accessJwt: tokens.accessToken, + refreshJwt: tokens.refreshToken!, + ); + + return BlueskyChat.fromSession(session, service: tokens.service); +} + atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) { return atp.ATProto.fromOAuthSession(session); } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 150e17e..5930949 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -24,6 +24,12 @@ import 'package:lazurite/features/notifications/presentation/notifications_scree import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; import 'package:lazurite/features/search/presentation/search_screen.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/messages/bloc/message_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; import 'package:lazurite/features/settings/presentation/about_screen.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; @@ -36,6 +42,7 @@ class AppRouter { final GlobalKey _searchNavigatorKey = GlobalKey(debugLabel: 'search'); final GlobalKey _notificationsNavigatorKey = GlobalKey(debugLabel: 'notifications'); final GlobalKey _profileNavigatorKey = GlobalKey(debugLabel: 'profile'); + final GlobalKey _messagesNavigatorKey = GlobalKey(debugLabel: 'messages'); final GlobalKey _settingsNavigatorKey = GlobalKey(debugLabel: 'settings'); GoRouter get router => GoRouter( @@ -163,6 +170,34 @@ class AppRouter { ), ], ), + StatefulShellBranch( + navigatorKey: _messagesNavigatorKey, + routes: [ + GoRoute( + path: '/messages', + builder: (context, state) => BlocProvider( + create: (_) => ConvoListBloc(convoRepository: context.read()), + child: const ConvoListScreen(), + ), + routes: [ + GoRoute( + path: ':id', + builder: (context, state) { + final convoId = state.pathParameters['id']!; + final args = state.extra as MessageThreadRouteArgs?; + return BlocProvider( + create: (_) => MessageBloc( + convoRepository: context.read(), + currentUserDid: context.read(), + ), + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), + ); + }, + ), + ], + ), + ], + ), StatefulShellBranch( navigatorKey: _settingsNavigatorKey, routes: [ diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index 17f84ca..35b544e 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -51,6 +51,11 @@ class AppShell extends StatelessWidget { label: 'Notifications', ), const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), + const NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Messages', + ), const NavigationDestination( icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), diff --git a/lib/features/messages/bloc/convo_list_bloc.dart b/lib/features/messages/bloc/convo_list_bloc.dart index d9fdd85..fbb11b8 100644 --- a/lib/features/messages/bloc/convo_list_bloc.dart +++ b/lib/features/messages/bloc/convo_list_bloc.dart @@ -15,6 +15,7 @@ class ConvoListBloc extends Bloc { on(_onConvosRefreshed); on(_onConvoMuted); on(_onConvoUnmuted); + on(_onConvoTabChanged); } final ConvoRepository _convoRepository; @@ -73,4 +74,8 @@ class ConvoListBloc extends Bloc { log.w('Failed to unmute conversation ${event.convoId}: $error'); } } + + void _onConvoTabChanged(ConvoTabChanged event, Emitter emit) { + emit(state.copyWith(activeTab: event.tab)); + } } diff --git a/lib/features/messages/bloc/convo_list_event.dart b/lib/features/messages/bloc/convo_list_event.dart index 0d3a59f..feb373a 100644 --- a/lib/features/messages/bloc/convo_list_event.dart +++ b/lib/features/messages/bloc/convo_list_event.dart @@ -37,3 +37,12 @@ class ConvoUnmuted extends ConvoListEvent { @override List get props => [convoId]; } + +class ConvoTabChanged extends ConvoListEvent { + const ConvoTabChanged({required this.tab}); + + final ConvoTab tab; + + @override + List get props => [tab]; +} diff --git a/lib/features/messages/bloc/message_bloc.dart b/lib/features/messages/bloc/message_bloc.dart index 8d2fc68..bda2fba 100644 --- a/lib/features/messages/bloc/message_bloc.dart +++ b/lib/features/messages/bloc/message_bloc.dart @@ -20,9 +20,10 @@ class MessageBloc extends Bloc { } final ConvoRepository _convoRepository; - // FIXME: use this final String _currentUserDid; + String get currentUserDid => _currentUserDid; + Future _onMessagesRequested(MessagesRequested event, Emitter emit) async { emit(const MessageState.loading()); @@ -39,6 +40,7 @@ class MessageBloc extends Bloc { ); } catch (error) { emit(MessageState.error('Failed to load messages: $error')); + log.e('Failed to load messages: $error'); } } @@ -62,6 +64,7 @@ class MessageBloc extends Bloc { ); } catch (error) { emit(state.copyWith(isLoadingMore: false, hasMore: false)); + log.e('Failed to load more messages: $error'); } } diff --git a/lib/features/messages/presentation/convo_list_screen.dart b/lib/features/messages/presentation/convo_list_screen.dart new file mode 100644 index 0000000..81a92f6 --- /dev/null +++ b/lib/features/messages/presentation/convo_list_screen.dart @@ -0,0 +1,162 @@ +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; +import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; + +class ConvoListScreen extends StatefulWidget { + const ConvoListScreen({super.key}); + + @override + State createState() => _ConvoListScreenState(); +} + +class _ConvoListScreenState extends State with SingleTickerProviderStateMixin { + late final TabController _tabController; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(_onTabChanged); + _scrollController.addListener(_onScroll); + context.read().add(const ConvosRequested()); + } + + @override + void dispose() { + _tabController + ..removeListener(_onTabChanged) + ..dispose(); + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (_tabController.indexIsChanging) return; + final tab = _tabController.index == 0 ? ConvoTab.primary : ConvoTab.requests; + context.read().add(ConvoTabChanged(tab: tab)); + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + log.d('Pagination not yet implemented'); + } + } + + Future _onRefresh() async => context.read().add(const ConvosRefreshed()); + + String _currentUserDid(BuildContext context) { + try { + return context.read(); + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Messages', style: Theme.of(context).textTheme.titleMedium), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Primary'), + Tab(text: 'Requests'), + ], + ), + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == ConvoListStatus.initial || + (state.status == ConvoListStatus.loading && state.convos.isEmpty)) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == ConvoListStatus.error && state.convos.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Failed to load messages', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.read().add(const ConvosRequested()), + child: const Text('Retry'), + ), + ], + ), + ); + } + + final filtered = _filteredConvos(state.convos, state.activeTab); + + if (filtered.isEmpty) { + return RefreshIndicator( + onRefresh: _onRefresh, + child: ListView( + controller: _scrollController, + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Center( + child: Text( + state.activeTab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ], + ), + ); + } + + final currentUserDid = _currentUserDid(context); + + return RefreshIndicator( + onRefresh: _onRefresh, + child: ListView.builder( + controller: _scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final convo = filtered[index]; + return ConvoListItem( + convo: convo, + currentUserDid: currentUserDid, + onTap: () => _openThread(context, convo, currentUserDid), + onMuteTap: () { + if (convo.muted) { + context.read().add(ConvoUnmuted(convoId: convo.id)); + } else { + context.read().add(ConvoMuted(convoId: convo.id)); + } + }, + ); + }, + ), + ); + }, + ), + ); + } + + List _filteredConvos(List convos, ConvoTab tab) => convos.where((c) { + final isRequest = c.status?.when(knownValue: (data) => data.value == 'request', unknown: (_) => false) ?? false; + return tab == ConvoTab.requests ? isRequest : !isRequest; + }).toList(); + + void _openThread(BuildContext context, ConvoView convo, String currentUserDid) { + final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; + final title = other?.displayName ?? other?.handle ?? 'Conversation'; + context.push('/messages/${convo.id}', extra: MessageThreadRouteArgs(title: title)); + } +} diff --git a/lib/features/messages/presentation/message_thread_route_args.dart b/lib/features/messages/presentation/message_thread_route_args.dart new file mode 100644 index 0000000..e933fb4 --- /dev/null +++ b/lib/features/messages/presentation/message_thread_route_args.dart @@ -0,0 +1,5 @@ +class MessageThreadRouteArgs { + const MessageThreadRouteArgs({required this.title}); + + final String title; +} diff --git a/lib/features/messages/presentation/message_thread_screen.dart b/lib/features/messages/presentation/message_thread_screen.dart new file mode 100644 index 0000000..4ed041f --- /dev/null +++ b/lib/features/messages/presentation/message_thread_screen.dart @@ -0,0 +1,193 @@ +import 'package:bluesky/chat_bsky_convo_getmessages.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/messages/bloc/message_bloc.dart'; +import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; + +class MessageThreadScreen extends StatefulWidget { + const MessageThreadScreen({super.key, required this.convoId, required this.title}); + + final String convoId; + final String title; + + @override + State createState() => _MessageThreadScreenState(); +} + +class _MessageThreadScreenState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + context.read() + ..add(MessagesRequested(convoId: widget.convoId)) + ..add(const ConvoMarkedRead()); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + context.read().add(const MessagesPageLoaded()); + } + } + + void _sendMessage() { + final text = _textController.text.trim(); + if (text.isEmpty) return; + _textController.clear(); + context.read().add(MessageSent(text: text)); + } + + void _copyAllMessages(List messages) { + final lines = messages.reversed + .map( + (m) => m.when(messageView: (data) => data.text, deletedMessageView: (_) => '[deleted]', unknown: (_) => ''), + ) + .where((t) => t.isNotEmpty) + .join('\n'); + Clipboard.setData(ClipboardData(text: lines)); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Thread copied'), duration: Duration(seconds: 2))); + } + + ThemeData get _theme => Theme.of(context); + + @override + Widget build(BuildContext context) { + final currentUserDid = context.read().currentUserDid; + + return Scaffold( + appBar: AppBar( + title: Text(widget.title, style: _theme.textTheme.titleMedium), + actions: [ + BlocBuilder( + builder: (context, state) => PopupMenuButton<_ThreadAction>( + onSelected: (action) { + if (action == _ThreadAction.copyAll && state.messages.isNotEmpty) { + _copyAllMessages(state.messages); + } + }, + itemBuilder: (_) => const [PopupMenuItem(value: _ThreadAction.copyAll, child: Text('Copy All'))], + ), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.status == MessageStatus.initial || + (state.status == MessageStatus.loading && state.messages.isEmpty)) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == MessageStatus.error && state.messages.isEmpty) { + return _buildErrorWidget(); + } + + if (state.messages.isEmpty) { + return Center(child: Text('No messages yet', style: _theme.textTheme.bodyLarge)); + } + + return ListView.builder( + controller: _scrollController, + reverse: true, + itemCount: state.messages.length + (state.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == state.messages.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + final message = state.messages[index]; + return message.when( + messageView: (data) => + MessageBubble(message: data, isCurrentUser: data.sender.did == currentUserDid), + deletedMessageView: (data) => const DeletedMessageBubble(isCurrentUser: false), + unknown: (_) => const SizedBox.shrink(), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 16), + _buildInputBar(context), + ], + ), + ); + } + + Widget _buildInputBar(BuildContext context) => SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _theme.colorScheme.surface, + border: Border(top: BorderSide(color: _theme.dividerColor)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + focusNode: _focusNode, + minLines: 1, + maxLines: 5, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Message…', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + isDense: true, + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + BlocBuilder( + builder: (context, state) => IconButton.filled( + onPressed: state.isSending ? null : _sendMessage, + icon: state.isSending + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : Icon(Icons.send, color: _theme.colorScheme.onPrimary), + ), + ), + ], + ), + ), + ); + + Widget _buildErrorWidget() => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Failed to load messages', style: _theme.textTheme.titleMedium), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.read().add(MessagesRequested(convoId: widget.convoId)), + child: const Text('Retry'), + ), + ], + ), + ); +} + +enum _ThreadAction { copyAll } diff --git a/lib/features/messages/presentation/widgets/convo_list_item.dart b/lib/features/messages/presentation/widgets/convo_list_item.dart new file mode 100644 index 0000000..564553e --- /dev/null +++ b/lib/features/messages/presentation/widgets/convo_list_item.dart @@ -0,0 +1,104 @@ +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter/material.dart'; + +class ConvoListItem extends StatelessWidget { + const ConvoListItem({ + super.key, + required this.convo, + required this.currentUserDid, + required this.onTap, + required this.onMuteTap, + }); + + final ConvoView convo; + final String currentUserDid; + final VoidCallback onTap; + final VoidCallback onMuteTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; + final displayName = other?.displayName ?? other?.handle ?? 'Unknown'; + final lastMessageText = _lastMessageText(); + + return ListTile( + onTap: onTap, + leading: _buildAvatar(context, other?.avatar), + title: Row( + children: [ + Expanded( + child: Text( + displayName, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: convo.unreadCount > 0 ? FontWeight.w700 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (convo.muted) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Icon(Icons.volume_off, size: 14, color: theme.colorScheme.onSurfaceVariant), + ), + if (convo.unreadCount > 0) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Badge( + label: Text( + convo.unreadCount > 99 ? '99+' : convo.unreadCount.toString(), + style: const TextStyle(fontSize: 10), + ), + ), + ), + ], + ), + subtitle: lastMessageText != null + ? Text( + lastMessageText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ) + : null, + trailing: PopupMenuButton<_ConvoAction>( + onSelected: (action) { + if (action == _ConvoAction.mute || action == _ConvoAction.unmute) { + onMuteTap(); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: convo.muted ? _ConvoAction.unmute : _ConvoAction.mute, + child: Text(convo.muted ? 'Unmute' : 'Mute'), + ), + ], + child: Icon(Icons.more_vert, color: theme.colorScheme.onSurfaceVariant), + ), + ); + } + + Widget _buildAvatar(BuildContext context, String? avatarUrl) { + final theme = Theme.of(context); + return CircleAvatar( + radius: 24, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null ? const Icon(Icons.person) : null, + ); + } + + String? _lastMessageText() { + final lastMessage = convo.lastMessage; + if (lastMessage == null) return null; + + return lastMessage.when( + messageView: (data) => data.text.isNotEmpty ? data.text : null, + deletedMessageView: (_) => 'Message deleted', + unknown: (_) => null, + ); + } +} + +enum _ConvoAction { mute, unmute } diff --git a/lib/features/messages/presentation/widgets/message_bubble.dart b/lib/features/messages/presentation/widgets/message_bubble.dart new file mode 100644 index 0000000..8c48d93 --- /dev/null +++ b/lib/features/messages/presentation/widgets/message_bubble.dart @@ -0,0 +1,90 @@ +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class MessageBubble extends StatelessWidget { + const MessageBubble({super.key, required this.message, required this.isCurrentUser}); + + final MessageView message; + final bool isCurrentUser; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + child: Align( + alignment: isCurrentUser ? Alignment.centerRight : Alignment.centerLeft, + child: GestureDetector( + onLongPress: () => _copyMessage(context), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isCurrentUser ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isCurrentUser ? 18 : 4), + bottomRight: Radius.circular(isCurrentUser ? 4 : 18), + ), + ), + child: Text( + message.text, + style: theme.textTheme.bodyMedium?.copyWith( + color: isCurrentUser ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface, + ), + ), + ), + ), + ), + ), + ); + } + + void _copyMessage(BuildContext context) { + Clipboard.setData(ClipboardData(text: message.text)); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Message copied'), duration: Duration(seconds: 2))); + } +} + +class DeletedMessageBubble extends StatelessWidget { + const DeletedMessageBubble({super.key, required this.isCurrentUser}); + + final bool isCurrentUser; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + child: Align( + alignment: isCurrentUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isCurrentUser ? 18 : 4), + bottomRight: Radius.circular(isCurrentUser ? 4 : 18), + ), + ), + child: Text( + 'Message deleted', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 6f30819..035ad67 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,8 +7,10 @@ import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/scheduler/post_scheduler.dart'; import 'package:lazurite/core/logging/logging_bloc_observer.dart'; import 'package:lazurite/core/logging/logging_navigator_observer.dart'; +import 'package:bluesky/bluesky_chat.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/router/app_router.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; @@ -80,13 +82,15 @@ class _LazuriteAppState extends State { } Bluesky? _createBluesky(AuthState state) { - if (!state.isAuthenticated) { - return null; - } - + if (!state.isAuthenticated) return null; return createBlueskyClient(state.tokens); } + BlueskyChat? _createBlueskyChat(AuthState state) { + if (!state.isAuthenticated) return null; + return createBlueSkyChatClient(state.tokens); + } + @override Widget build(BuildContext context) { return MultiBlocProvider( @@ -97,6 +101,7 @@ class _LazuriteAppState extends State { child: BlocBuilder( builder: (context, authState) { final bluesky = _createBluesky(authState); + final blueskyChat = _createBlueskyChat(authState); final appShell = BlocBuilder( builder: (context, settingsState) { final themeMode = settingsState.useSystemTheme @@ -117,7 +122,7 @@ class _LazuriteAppState extends State { }, ); - if (bluesky == null) { + if (bluesky == null || blueskyChat == null) { return appShell; } @@ -160,6 +165,7 @@ class _LazuriteAppState extends State { RepositoryProvider(create: (_) => PostActionCache()), RepositoryProvider.value(value: profileActionRepository), RepositoryProvider.value(value: bluesky), + RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), RepositoryProvider.value(value: widget.database), RepositoryProvider.value(value: accountDid), ], diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index bfa0fea..7664559 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,27 +1,20 @@ { "compilerOptions": { - // Environment setup & latest features "lib": ["ESNext"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, - - // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, - - // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - - // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false diff --git a/test/features/messages/bloc/convo_list_bloc_test.dart b/test/features/messages/bloc/convo_list_bloc_test.dart index d3c73e7..7351a36 100644 --- a/test/features/messages/bloc/convo_list_bloc_test.dart +++ b/test/features/messages/bloc/convo_list_bloc_test.dart @@ -138,5 +138,21 @@ void main() { act: (bloc) => bloc.add(const ConvoMuted(convoId: 'c1')), expect: () => [], ); + + blocTest( + 'switches active tab on ConvoTabChanged', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + seed: () => const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), + act: (bloc) => bloc.add(const ConvoTabChanged(tab: ConvoTab.requests)), + expect: () => [predicate((state) => state.activeTab == ConvoTab.requests)], + ); + + blocTest( + 'switches back to primary tab on ConvoTabChanged', + build: () => ConvoListBloc(convoRepository: mockConvoRepository), + seed: () => const ConvoListState.loaded(convos: [], cursor: null, hasMore: false, activeTab: ConvoTab.requests), + act: (bloc) => bloc.add(const ConvoTabChanged(tab: ConvoTab.primary)), + expect: () => [predicate((state) => state.activeTab == ConvoTab.primary)], + ); }); } diff --git a/test/features/messages/presentation/convo_list_screen_test.dart b/test/features/messages/presentation/convo_list_screen_test.dart new file mode 100644 index 0000000..54c91ff --- /dev/null +++ b/test/features/messages/presentation/convo_list_screen_test.dart @@ -0,0 +1,194 @@ +import 'package:bluesky/chat_bsky_actor_defs.dart'; +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConvoRepository extends Mock implements ConvoRepository {} + +void main() { + const currentUserDid = 'did:plc:me'; + + late MockConvoRepository mockRepository; + + setUp(() { + mockRepository = MockConvoRepository(); + }); + + ProfileViewBasic makeProfile({String did = 'did:plc:other', String handle = 'other.bsky.social'}) => + ProfileViewBasic(did: did, handle: handle); + + ConvoView makeConvo({String id = 'c1', bool muted = false, int unreadCount = 0}) => ConvoView( + id: id, + rev: 'rev-1', + members: [ + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), + makeProfile(), + ], + muted: muted, + unreadCount: unreadCount, + ); + + Widget buildSubject() { + return RepositoryProvider.value( + value: currentUserDid, + child: MaterialApp( + home: BlocProvider( + create: (_) => ConvoListBloc(convoRepository: mockRepository), + child: const ConvoListScreen(), + ), + ), + ); + } + + group('ConvoListScreen', () { + testWidgets('shows loading indicator initially', (tester) async { + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); + + await tester.pumpWidget(buildSubject()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows Primary and Requests tabs', (tester) async { + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Primary'), findsOneWidget); + expect(find.text('Requests'), findsOneWidget); + }); + + testWidgets('shows conversations after loading', (tester) async { + final convos = [makeConvo(id: 'c1'), makeConvo(id: 'c2')]; + + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: convos, cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.byType(ListView), findsOneWidget); + }); + + testWidgets('shows empty state when no conversations', (tester) async { + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('No conversations yet'), findsOneWidget); + }); + + testWidgets('shows error state on failure', (tester) async { + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Failed to load messages'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('retry button reloads conversations', (tester) async { + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + verify( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).called(greaterThanOrEqualTo(1)); + }); + + testWidgets('filters to requests tab', (tester) async { + final acceptedConvo = makeConvo(id: 'accepted'); + final requestConvo = ConvoView( + id: 'request', + rev: 'rev-1', + members: [ + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), + makeProfile(handle: 'requester.bsky.social'), + ], + muted: false, + unreadCount: 0, + status: const ConvoViewStatus.knownValue(data: KnownConvoViewStatus.request), + ); + + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [acceptedConvo, requestConvo], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('other.bsky.social'), findsOneWidget); + + await tester.tap(find.text('Requests')); + await tester.pumpAndSettle(); + + expect(find.text('requester.bsky.social'), findsOneWidget); + expect(find.text('No conversations yet'), findsNothing); + }); + + testWidgets('shows no message requests in requests tab when none exist', (tester) async { + when( + () => mockRepository.listConvos( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => ConvoListResult(convos: [makeConvo()], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Requests')); + await tester.pumpAndSettle(); + + expect(find.text('No message requests'), findsOneWidget); + }); + }); +} diff --git a/test/features/messages/presentation/message_thread_screen_test.dart b/test/features/messages/presentation/message_thread_screen_test.dart new file mode 100644 index 0000000..cf5c400 --- /dev/null +++ b/test/features/messages/presentation/message_thread_screen_test.dart @@ -0,0 +1,279 @@ +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:bluesky/chat_bsky_convo_getmessages.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/messages/bloc/message_bloc.dart'; +import 'package:lazurite/features/messages/data/convo_repository.dart'; +import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; +import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConvoRepository extends Mock implements ConvoRepository {} + +void main() { + const currentUserDid = 'did:plc:me'; + const otherDid = 'did:plc:other'; + const convoId = 'convo-123'; + + late MockConvoRepository mockRepository; + + setUp(() { + mockRepository = MockConvoRepository(); + }); + + MessageView makeMessageView({String id = 'msg-1', String text = 'Hello', String senderDid = otherDid}) => MessageView( + id: id, + rev: 'rev-1', + text: text, + sender: MessageViewSender(did: senderDid), + sentAt: DateTime.utc(2026, 3, 15), + ); + + UConvoGetMessagesMessages makeMessage({String id = 'msg-1', String text = 'Hello', String senderDid = otherDid}) => + UConvoGetMessagesMessages.messageView( + data: makeMessageView(id: id, text: text, senderDid: senderDid), + ); + + Widget buildSubject({String title = 'Test Convo'}) { + return RepositoryProvider.value( + value: currentUserDid, + child: MaterialApp( + home: BlocProvider( + create: (_) => MessageBloc(convoRepository: mockRepository, currentUserDid: currentUserDid), + child: MessageThreadScreen(convoId: convoId, title: title), + ), + ), + ); + } + + group('MessageThreadScreen', () { + testWidgets('shows loading indicator initially', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows appbar title', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject(title: 'Alice')); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + }); + + testWidgets('shows empty state when no messages', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('No messages yet'), findsOneWidget); + }); + + testWidgets('shows error and retry button on failure', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Failed to load messages'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('renders messages as bubbles', (tester) async { + final messages = [ + makeMessage(id: 'msg-1', text: 'Hi there', senderDid: otherDid), + makeMessage(id: 'msg-2', text: 'Hello back', senderDid: currentUserDid), + ]; + + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.byType(MessageBubble), findsNWidgets(2)); + expect(find.text('Hi there'), findsOneWidget); + expect(find.text('Hello back'), findsOneWidget); + }); + + testWidgets('current user message is right-aligned', (tester) async { + final messages = [makeMessage(id: 'msg-1', text: 'My message', senderDid: currentUserDid)]; + + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + final bubble = tester.widget(find.byType(MessageBubble)); + expect(bubble.isCurrentUser, isTrue); + }); + + testWidgets('other user message is left-aligned', (tester) async { + final messages = [makeMessage(id: 'msg-1', text: 'Their message', senderDid: otherDid)]; + + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + final bubble = tester.widget(find.byType(MessageBubble)); + expect(bubble.isCurrentUser, isFalse); + }); + + testWidgets('sends message on send button tap', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + when( + () => mockRepository.sendMessage(any(), any()), + ).thenAnswer((_) async => makeMessageView(id: 'new-msg', text: 'New message', senderDid: currentUserDid)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'New message'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + verify(() => mockRepository.sendMessage(convoId, 'New message')).called(1); + }); + + testWidgets('does not send empty message', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.send)); + await tester.pump(); + + verifyNever(() => mockRepository.sendMessage(any(), any())); + }); + + testWidgets('clears input after send', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + when( + () => mockRepository.sendMessage(any(), any()), + ).thenAnswer((_) async => makeMessageView(id: 'new-msg', text: 'Hi', senderDid: currentUserDid)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Hi'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pumpAndSettle(); + + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, isEmpty); + }); + + testWidgets('shows Copy All in overflow menu', (tester) async { + final messages = [makeMessage(id: 'msg-1', text: 'Hello', senderDid: otherDid)]; + + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: messages, cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Copy All'), findsOneWidget); + }); + + testWidgets('marks conversation as read on open', (tester) async { + when( + () => mockRepository.getMessages( + any(), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => MessageListResult(messages: [], cursor: null)); + when(() => mockRepository.updateRead(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + verify(() => mockRepository.updateRead(convoId)).called(1); + }); + }); +} diff --git a/test/features/messages/presentation/widgets/convo_list_item_test.dart b/test/features/messages/presentation/widgets/convo_list_item_test.dart new file mode 100644 index 0000000..d53be3b --- /dev/null +++ b/test/features/messages/presentation/widgets/convo_list_item_test.dart @@ -0,0 +1,165 @@ +import 'package:bluesky/chat_bsky_actor_defs.dart'; +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; + +void main() { + const currentUserDid = 'did:plc:currentuser'; + const otherDid = 'did:plc:other'; + + ProfileViewBasic makeProfile({ + String did = otherDid, + String handle = 'other.bsky.social', + String? displayName, + String? avatar, + }) => ProfileViewBasic(did: did, handle: handle, displayName: displayName, avatar: avatar); + + ConvoView makeConvo({ + String id = 'c1', + bool muted = false, + int unreadCount = 0, + UConvoViewLastMessage? lastMessage, + List? members, + }) => ConvoView( + id: id, + rev: 'rev-1', + members: members ?? [makeProfile(did: currentUserDid, handle: 'me.bsky.social'), makeProfile()], + muted: muted, + unreadCount: unreadCount, + lastMessage: lastMessage, + ); + + Widget buildSubject({required ConvoView convo, VoidCallback? onTap, VoidCallback? onMuteTap}) { + return MaterialApp( + home: Scaffold( + body: ConvoListItem( + convo: convo, + currentUserDid: currentUserDid, + onTap: onTap ?? () {}, + onMuteTap: onMuteTap ?? () {}, + ), + ), + ); + } + + group('ConvoListItem', () { + testWidgets('displays the other member display name', (tester) async { + final convo = makeConvo( + members: [ + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), + makeProfile(displayName: 'Alice Smith'), + ], + ); + + await tester.pumpWidget(buildSubject(convo: convo)); + + expect(find.text('Alice Smith'), findsOneWidget); + }); + + testWidgets('falls back to handle when no display name', (tester) async { + final convo = makeConvo( + members: [ + makeProfile(did: currentUserDid, handle: 'me.bsky.social'), + makeProfile(handle: 'alice.bsky.social'), + ], + ); + + await tester.pumpWidget(buildSubject(convo: convo)); + + expect(find.text('alice.bsky.social'), findsOneWidget); + }); + + testWidgets('shows last message text', (tester) async { + final lastMsg = UConvoViewLastMessage.messageView( + data: MessageView( + id: 'msg-1', + rev: 'rev-1', + text: 'Hello there!', + sender: const MessageViewSender(did: otherDid), + sentAt: DateTime.utc(2026, 3, 15), + ), + ); + final convo = makeConvo(lastMessage: lastMsg); + + await tester.pumpWidget(buildSubject(convo: convo)); + + expect(find.text('Hello there!'), findsOneWidget); + }); + + testWidgets('shows "Message deleted" for deleted last message', (tester) async { + final lastMsg = UConvoViewLastMessage.deletedMessageView( + data: DeletedMessageView( + id: 'msg-1', + rev: 'rev-1', + sender: const MessageViewSender(did: otherDid), + sentAt: DateTime.utc(2026, 3, 15), + ), + ); + final convo = makeConvo(lastMessage: lastMsg); + + await tester.pumpWidget(buildSubject(convo: convo)); + + expect(find.text('Message deleted'), findsOneWidget); + }); + + testWidgets('shows mute icon when conversation is muted', (tester) async { + final convo = makeConvo(muted: true); + + await tester.pumpWidget(buildSubject(convo: convo)); + + expect(find.byIcon(Icons.volume_off), findsOneWidget); + }); + + testWidgets('does not show mute icon when not muted', (tester) async { + final convo = makeConvo(muted: false); + + await tester.pumpWidget(buildSubject(convo: convo)); + + expect(find.byIcon(Icons.volume_off), findsNothing); + }); + + testWidgets('shows unread count badge', (tester) async { + final convo = makeConvo(unreadCount: 5); + + await tester.pumpWidget(buildSubject(convo: convo)); + + expect(find.text('5'), findsOneWidget); + }); + + testWidgets('calls onTap when tapped', (tester) async { + var tapped = false; + final convo = makeConvo(); + + await tester.pumpWidget(buildSubject(convo: convo, onTap: () => tapped = true)); + await tester.tap(find.byType(ListTile)); + + expect(tapped, isTrue); + }); + + testWidgets('calls onMuteTap when mute menu item selected', (tester) async { + var muteTapped = false; + final convo = makeConvo(muted: false); + + await tester.pumpWidget(buildSubject(convo: convo, onMuteTap: () => muteTapped = true)); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Mute')); + await tester.pumpAndSettle(); + + expect(muteTapped, isTrue); + }); + + testWidgets('shows Unmute option for muted conversation', (tester) async { + final convo = makeConvo(muted: true); + + await tester.pumpWidget(buildSubject(convo: convo)); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Unmute'), findsOneWidget); + }); + }); +} diff --git a/test/features/messages/presentation/widgets/message_bubble_test.dart b/test/features/messages/presentation/widgets/message_bubble_test.dart new file mode 100644 index 0000000..cc12bd6 --- /dev/null +++ b/test/features/messages/presentation/widgets/message_bubble_test.dart @@ -0,0 +1,81 @@ +import 'package:bluesky/chat_bsky_convo_defs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; + +void main() { + MessageView makeMessage({String id = 'msg-1', String text = 'Hello', String senderDid = 'did:plc:sender'}) => + MessageView( + id: id, + rev: 'rev-1', + text: text, + sender: MessageViewSender(did: senderDid), + sentAt: DateTime.utc(2026, 3, 15), + ); + + group('MessageBubble', () { + testWidgets('displays message text', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MessageBubble(message: makeMessage(text: 'Test message'), isCurrentUser: false), + ), + ), + ); + + expect(find.text('Test message'), findsOneWidget); + }); + + testWidgets('right-aligns current user bubble', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MessageBubble(message: makeMessage(), isCurrentUser: true)), + ), + ); + + final align = tester.widget(find.byType(Align).first); + expect(align.alignment, Alignment.centerRight); + }); + + testWidgets('left-aligns other user bubble', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MessageBubble(message: makeMessage(), isCurrentUser: false)), + ), + ); + + final align = tester.widget(find.byType(Align).first); + expect(align.alignment, Alignment.centerLeft); + }); + + testWidgets('shows snackbar on long press', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MessageBubble(message: makeMessage(text: 'Copy me'), isCurrentUser: false), + ), + ), + ); + + await tester.longPress(find.byType(GestureDetector).first); + await tester.pumpAndSettle(); + + expect(find.text('Message copied'), findsOneWidget); + }); + }); + + group('DeletedMessageBubble', () { + testWidgets('displays deleted message text', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: DeletedMessageBubble(isCurrentUser: false)))); + + expect(find.text('Message deleted'), findsOneWidget); + }); + + testWidgets('right-aligns current user deleted bubble', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: DeletedMessageBubble(isCurrentUser: true)))); + + final align = tester.widget(find.byType(Align).first); + expect(align.alignment, Alignment.centerRight); + }); + }); +} diff --git a/www/client-metadata.json b/www/client-metadata.json index 8b8d257..834132e 100644 --- a/www/client-metadata.json +++ b/www/client-metadata.json @@ -3,7 +3,7 @@ "client_name": "Lazurite", "client_uri": "https://lazurite.stormlightlabs.org", "redirect_uris": ["http://127.0.0.1/callback"], - "scope": "atproto transition:generic", + "scope": "atproto transition:generic transition:chat.bsky", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none",