From 8a3bd631ee9e257044f746ab80899c2d1fd900a6 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 08:46:12 +0530 Subject: [PATCH 01/11] compose [nfc]: Export and rename _File to FileToUpload --- lib/widgets/compose_box.dart | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 99eeef3b0d..bdfab54f94 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -911,8 +911,8 @@ class _EditMessageContentInput extends StatelessWidget { /// /// A convenience class to represent data from the generic file picker, /// the media library, and the camera, in a single form. -class _File { - _File({ +class FileToUpload { + FileToUpload({ required this.content, required this.length, required this.filename, @@ -929,14 +929,14 @@ Future _uploadFiles({ required BuildContext context, required ComposeContentController contentController, required FocusNode contentFocusNode, - required Iterable<_File> files, + required Iterable files, }) async { assert(context.mounted); final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - final List<_File> tooLargeFiles = []; - final List<_File> rightSizeFiles = []; + final List tooLargeFiles = []; + final List rightSizeFiles = []; for (final file in files) { if ((file.length / (1 << 20)) > store.maxFileUploadSizeMib) { tooLargeFiles.add(file); @@ -959,7 +959,7 @@ Future _uploadFiles({ listMessage)); } - final List<(int, _File)> uploadsInProgress = []; + final List<(int, FileToUpload)> uploadsInProgress = []; for (final file in rightSizeFiles) { final tag = contentController.registerUploadStart(file.filename, zulipLocalizations); @@ -970,7 +970,7 @@ Future _uploadFiles({ } for (final (tag, file) in uploadsInProgress) { - final _File(:content, :length, :filename, :mimeType) = file; + final FileToUpload(:content, :length, :filename, :mimeType) = file; String? url; try { final result = await uploadFile(store.connection, @@ -1009,7 +1009,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { /// /// To signal exiting the interaction with no files chosen, /// return an empty [Iterable] after showing user feedback as appropriate. - Future> getFiles(BuildContext context); + Future> getFiles(BuildContext context); void _handlePress(BuildContext context) async { final files = await getFiles(context); @@ -1043,7 +1043,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { } } -Future> _getFilePickerFiles(BuildContext context, FileType type) async { +Future> _getFilePickerFiles(BuildContext context, FileType type) async { FilePickerResult? result; try { result = await ZulipBinding.instance @@ -1088,7 +1088,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) f.path ?? '', headerBytes: f.bytes?.take(defaultMagicNumbersMaxLength).toList(), ); - return _File( + return FileToUpload( content: f.readStream!, length: f.size, filename: f.name, @@ -1108,7 +1108,7 @@ class _AttachFileButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachFilesTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { return _getFilePickerFiles(context, FileType.any); } } @@ -1124,7 +1124,7 @@ class _AttachMediaButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachMediaTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { // TODO(#114): This doesn't give quite the right UI on Android. return _getFilePickerFiles(context, FileType.media); } @@ -1141,7 +1141,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { zulipLocalizations.composeBoxAttachFromCameraTooltip; @override - Future> getFiles(BuildContext context) async { + Future> getFiles(BuildContext context) async { final zulipLocalizations = ZulipLocalizations.of(context); final XFile? result; try { @@ -1192,7 +1192,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { } catch (e) { // TODO(log) } - return [_File( + return [FileToUpload( content: result.openRead(), length: length, filename: result.name, From 299bd86859316b45708cdf0575c44004617ea593 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 08:52:31 +0530 Subject: [PATCH 02/11] msglist [nfc]: Allow passing `key` in `MessageListPage.buildRoute` This will be used soon to access `MessageListPageState` after routing to `MessageListPage`. --- lib/widgets/message_list.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index fa6c8b9229..4217d8cb73 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -133,7 +133,7 @@ class MessageListTheme extends ThemeExtension { /// The interface for the state of a [MessageListPage]. /// /// To obtain one of these, see [MessageListPage.ancestorOf]. -abstract class MessageListPageState { +abstract class MessageListPageState extends State { /// The narrow for this page's message list. Narrow get narrow; @@ -171,11 +171,20 @@ class MessageListPage extends StatefulWidget { this.initAnchorMessageId, }); - static AccountRoute buildRoute({int? accountId, BuildContext? context, - required Narrow narrow, int? initAnchorMessageId}) { - return MaterialAccountWidgetRoute(accountId: accountId, context: context, + static AccountRoute buildRoute({ + GlobalKey? key, + int? accountId, + BuildContext? context, + required Narrow narrow, + int? initAnchorMessageId, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId, + context: context, page: MessageListPage( - initNarrow: narrow, initAnchorMessageId: initAnchorMessageId)); + key: key, + initNarrow: narrow, + initAnchorMessageId: initAnchorMessageId)); } /// The "revealed" state of a message from a muted sender, From 0abdd747a1f887f7de06c7b2ab15239309c99730 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 09:04:17 +0530 Subject: [PATCH 03/11] subscription_list [nfc]: Allow disabling topic list button in channel action sheet This will be used soon to avoid unintended flows when sharing content received from other apps. --- lib/widgets/action_sheet.dart | 4 +++- lib/widgets/subscription_list.dart | 28 +++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 7adfb2ba24..f344330e77 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -241,6 +241,7 @@ enum BottomSheetDismissButtonStyle { /// Needs a [PageRoot] ancestor. void showChannelActionSheet(BuildContext context, { required int channelId, + bool showTopicListButton = true, }) { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); @@ -254,7 +255,8 @@ void showChannelActionSheet(BuildContext context, { [ if (unreadCount > 0) MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId), - TopicListButton(pageContext: pageContext, channelId: channelId), + if (showTopicListButton) + TopicListButton(pageContext: pageContext, channelId: channelId), CopyChannelLinkButton(channelId: channelId, pageContext: pageContext) ], if (isSubscribed) diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index d64c578c5e..b8e8894d64 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -15,7 +15,12 @@ import 'unread_count_badge.dart'; /// Scrollable listing of subscribed streams. class SubscriptionListPageBody extends StatefulWidget { - const SubscriptionListPageBody({super.key}); + const SubscriptionListPageBody({ + super.key, + this.showTopicListButtonInActionSheet = true, + }); + + final bool showTopicListButtonInActionSheet; @override State createState() => _SubscriptionListPageBodyState(); @@ -106,11 +111,17 @@ class _SubscriptionListPageBodyState extends State wit slivers: [ if (pinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.pinnedSubscriptionsLabel), - _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), + _SubscriptionList( + unreadsModel: unreadsModel, + subscriptions: pinned, + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet), ], if (unpinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.unpinnedSubscriptionsLabel), - _SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned), + _SubscriptionList( + unreadsModel: unreadsModel, + subscriptions: unpinned, + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet), ], // TODO(#188): add button leading to "All Streams" page with ability to subscribe @@ -160,10 +171,12 @@ class _SubscriptionList extends StatelessWidget { const _SubscriptionList({ required this.unreadsModel, required this.subscriptions, + required this.showTopicListButtonInActionSheet, }); final Unreads? unreadsModel; final List subscriptions; + final bool showTopicListButtonInActionSheet; @override Widget build(BuildContext context) { @@ -176,7 +189,8 @@ class _SubscriptionList extends StatelessWidget { && unreadsModel!.countInChannelNarrow(subscription.streamId) > 0; return SubscriptionItem(subscription: subscription, unreadCount: unreadCount, - showMutedUnreadBadge: showMutedUnreadBadge); + showMutedUnreadBadge: showMutedUnreadBadge, + showTopicListButtonInActionSheet: showTopicListButtonInActionSheet); }); } } @@ -188,11 +202,13 @@ class SubscriptionItem extends StatelessWidget { required this.subscription, required this.unreadCount, required this.showMutedUnreadBadge, + required this.showTopicListButtonInActionSheet, }); final Subscription subscription; final int unreadCount; final bool showMutedUnreadBadge; + final bool showTopicListButtonInActionSheet; @override Widget build(BuildContext context) { @@ -210,7 +226,9 @@ class SubscriptionItem extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: ChannelNarrow(subscription.streamId))); }, - onLongPress: () => showChannelActionSheet(context, channelId: subscription.streamId), + onLongPress: () => showChannelActionSheet(context, + channelId: subscription.streamId, + showTopicListButton: showTopicListButtonInActionSheet), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(width: 16), Padding( From 166d1102a2c63235498f6d7d7cce6d7327a98d82 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 09:14:30 +0530 Subject: [PATCH 04/11] subscription_list [nfc]: Add a flag to hide channels where the user can't post This will be used soon to avoid showing conversations from the channel list where the current user can't post, specifically when they don't have the permission. --- lib/widgets/subscription_list.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index b8e8894d64..a8ce75c4d9 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -18,9 +18,11 @@ class SubscriptionListPageBody extends StatefulWidget { const SubscriptionListPageBody({ super.key, this.showTopicListButtonInActionSheet = true, + this.hideChannelsIfUserCantPost = false, }); final bool showTopicListButtonInActionSheet; + final bool hideChannelsIfUserCantPost; @override State createState() => _SubscriptionListPageBodyState(); @@ -91,6 +93,12 @@ class _SubscriptionListPageBodyState extends State wit final List pinned = []; final List unpinned = []; for (final subscription in store.subscriptions.values) { + if (widget.hideChannelsIfUserCantPost) { + if (!store.hasPostingPermission(inChannel: subscription, + user: store.selfUser, byDate: DateTime.now())) { + continue; + } + } if (subscription.pinToTop) { pinned.add(subscription); } else { From 005f700e9c3a02476b513848d00fe9bc2141e6f8 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 09:20:13 +0530 Subject: [PATCH 05/11] subscription_list [nfc]: Allow `onChannelSelect` callback, notifying the selected channel This will be used soon to provide specific behaviour when selecting a channel, where if specified it will replace the default behaviour of routing to the message list page of the selected channel narrow. --- lib/widgets/subscription_list.dart | 37 +++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index a8ce75c4d9..de791ed4f7 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -13,17 +13,27 @@ import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; +typedef OnChannelSelectCallback = void Function(ChannelNarrow narrow); + /// Scrollable listing of subscribed streams. class SubscriptionListPageBody extends StatefulWidget { const SubscriptionListPageBody({ super.key, this.showTopicListButtonInActionSheet = true, this.hideChannelsIfUserCantPost = false, + this.onChannelSelect, }); final bool showTopicListButtonInActionSheet; final bool hideChannelsIfUserCantPost; + /// Callback to invoke when the user selects a channel from the list. + /// + /// If null, the default behavior is to navigate to the channel feed. + final OnChannelSelectCallback? onChannelSelect; + + // TODO(#412) add onTopicSelect + @override State createState() => _SubscriptionListPageBodyState(); } @@ -72,6 +82,12 @@ class _SubscriptionListPageBodyState extends State wit }); } + void _handleChannelSelect(ChannelNarrow narrow) { + Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: narrow)); + } + @override Widget build(BuildContext context) { // Design referenced from: @@ -114,6 +130,8 @@ class _SubscriptionListPageBodyState extends State wit message: zulipLocalizations.channelsEmptyPlaceholder); } + final onChannelSelect = widget.onChannelSelect ?? _handleChannelSelect; + return SafeArea( // horizontal insets child: CustomScrollView( slivers: [ @@ -122,14 +140,16 @@ class _SubscriptionListPageBodyState extends State wit _SubscriptionList( unreadsModel: unreadsModel, subscriptions: pinned, - showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet), + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet, + onChannelSelect: onChannelSelect), ], if (unpinned.isNotEmpty) ...[ _SubscriptionListHeader(label: zulipLocalizations.unpinnedSubscriptionsLabel), _SubscriptionList( unreadsModel: unreadsModel, subscriptions: unpinned, - showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet), + showTopicListButtonInActionSheet: widget.showTopicListButtonInActionSheet, + onChannelSelect: onChannelSelect), ], // TODO(#188): add button leading to "All Streams" page with ability to subscribe @@ -180,11 +200,13 @@ class _SubscriptionList extends StatelessWidget { required this.unreadsModel, required this.subscriptions, required this.showTopicListButtonInActionSheet, + required this.onChannelSelect, }); final Unreads? unreadsModel; final List subscriptions; final bool showTopicListButtonInActionSheet; + final OnChannelSelectCallback onChannelSelect; @override Widget build(BuildContext context) { @@ -198,7 +220,8 @@ class _SubscriptionList extends StatelessWidget { return SubscriptionItem(subscription: subscription, unreadCount: unreadCount, showMutedUnreadBadge: showMutedUnreadBadge, - showTopicListButtonInActionSheet: showTopicListButtonInActionSheet); + showTopicListButtonInActionSheet: showTopicListButtonInActionSheet, + onChannelSelect: onChannelSelect); }); } } @@ -211,12 +234,14 @@ class SubscriptionItem extends StatelessWidget { required this.unreadCount, required this.showMutedUnreadBadge, required this.showTopicListButtonInActionSheet, + required this.onChannelSelect, }); final Subscription subscription; final int unreadCount; final bool showMutedUnreadBadge; final bool showTopicListButtonInActionSheet; + final OnChannelSelectCallback onChannelSelect; @override Widget build(BuildContext context) { @@ -229,11 +254,7 @@ class SubscriptionItem extends StatelessWidget { // TODO(design) check if this is the right variable color: designVariables.background, child: InkWell( - onTap: () { - Navigator.push(context, - MessageListPage.buildRoute(context: context, - narrow: ChannelNarrow(subscription.streamId))); - }, + onTap: () => onChannelSelect(ChannelNarrow(subscription.streamId)), onLongPress: () => showChannelActionSheet(context, channelId: subscription.streamId, showTopicListButton: showTopicListButtonInActionSheet), From ef6dcf6de3d30a69f3b3aa2efceefb1a92b8c1fa Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 09:28:58 +0530 Subject: [PATCH 06/11] recent dms [nfc]: Add a flag to hide DMs where user can't post This will be used soon to avoid showing conversations from the recent DMs list where the current user can't post, specifically when a conversation has one or more deactivated user. --- lib/widgets/recent_dm_conversations.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index f4846bf943..701c39bd5d 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -15,7 +15,12 @@ import 'unread_count_badge.dart'; import 'user.dart'; class RecentDmConversationsPageBody extends StatefulWidget { - const RecentDmConversationsPageBody({super.key}); + const RecentDmConversationsPageBody({ + super.key, + this.hideDmsIfUserCantPost = false, + }); + + final bool hideDmsIfUserCantPost; @override State createState() => _RecentDmConversationsPageBodyState(); @@ -76,6 +81,14 @@ class _RecentDmConversationsPageBodyState extends State !(store.getUser(id)?.isActive ?? true)); + if (hasDeactivatedUser) { + return SizedBox.shrink(); + } + } return RecentDmConversationsItem( narrow: narrow, unreadCount: unreadsModel!.countInDmNarrow(narrow)); From 412f911948da873677dace9e60cfc7fb40900d4a Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 09:34:49 +0530 Subject: [PATCH 07/11] recent dms [nfc]: Allow `onDmSelect` callback, notifying the selected DM This will be used soon to provide a specific behaviour when selecting a DM, where if specified it will replace the default behaviour of routing to the message list page of the selected DM narrow. --- lib/widgets/new_dm_sheet.dart | 18 +++++---- lib/widgets/recent_dm_conversations.dart | 50 ++++++++++++++++++++---- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index 93fbf5c354..05e682e849 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -6,14 +6,14 @@ import '../model/narrow.dart'; import '../model/store.dart'; import 'color.dart'; import 'icons.dart'; -import 'message_list.dart'; import 'page.dart'; +import 'recent_dm_conversations.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; import 'user.dart'; -void showNewDmSheet(BuildContext context) { +void showNewDmSheet(BuildContext context, OnDmSelectCallback onDmSelect) { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(context); showModalBottomSheet( @@ -29,12 +29,14 @@ void showNewDmSheet(BuildContext context) { padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: PerAccountStoreWidget( accountId: store.accountId, - child: NewDmPicker()))); + child: NewDmPicker(onDmSelect: onDmSelect)))); } @visibleForTesting class NewDmPicker extends StatefulWidget { - const NewDmPicker({super.key}); + const NewDmPicker({super.key, required this.onDmSelect}); + + final OnDmSelectCallback onDmSelect; @override State createState() => _NewDmPickerState(); @@ -132,7 +134,7 @@ class _NewDmPickerState extends State with PerAccountStoreAwareStat @override Widget build(BuildContext context) { return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _NewDmHeader(selectedUserIds: selectedUserIds), + _NewDmHeader(selectedUserIds: selectedUserIds, onDmSelect: widget.onDmSelect), _NewDmSearchBar( controller: searchController, selectedUserIds: selectedUserIds, @@ -148,9 +150,10 @@ class _NewDmPickerState extends State with PerAccountStoreAwareStat } class _NewDmHeader extends StatelessWidget { - const _NewDmHeader({required this.selectedUserIds}); + const _NewDmHeader({required this.selectedUserIds, required this.onDmSelect}); final Set selectedUserIds; + final OnDmSelectCallback onDmSelect; Widget _buildCancelButton(BuildContext context) { final designVariables = DesignVariables.of(context); @@ -178,8 +181,7 @@ class _NewDmHeader extends StatelessWidget { final narrow = DmNarrow.withUsers( selectedUserIds.toList(), selfUserId: store.selfUserId); - Navigator.pushReplacement(context, - MessageListPage.buildRoute(context: context, narrow: narrow)); + onDmSelect(narrow); }, child: Text(zulipLocalizations.newDmSheetComposeButtonLabel, style: TextStyle( diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 701c39bd5d..c2e48aae1e 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -14,14 +14,22 @@ import 'theme.dart'; import 'unread_count_badge.dart'; import 'user.dart'; +typedef OnDmSelectCallback = void Function(DmNarrow narrow); + class RecentDmConversationsPageBody extends StatefulWidget { const RecentDmConversationsPageBody({ super.key, this.hideDmsIfUserCantPost = false, + this.onDmSelect, }); final bool hideDmsIfUserCantPost; + /// Callback to invoke when the user selects a DM conversation from the list. + /// + /// If null, the default behavior is to navigate to the DM conversation. + final OnDmSelectCallback? onDmSelect; + @override State createState() => _RecentDmConversationsPageBodyState(); } @@ -55,6 +63,28 @@ class _RecentDmConversationsPageBodyState extends State onDmSelect(narrow), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8), @@ -188,7 +218,11 @@ class RecentDmConversationsItem extends StatelessWidget { } class _NewDmButton extends StatefulWidget { - const _NewDmButton(); + const _NewDmButton({ + required this.onDmSelect, + }); + + final OnDmSelectCallback onDmSelect; @override State<_NewDmButton> createState() => _NewDmButtonState(); @@ -210,7 +244,7 @@ class _NewDmButtonState extends State<_NewDmButton> { : designVariables.fabLabel; return GestureDetector( - onTap: () => showNewDmSheet(context), + onTap: () => showNewDmSheet(context, widget.onDmSelect), onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), From fb4d254c8db718a8d83b3af47321176bb885e830 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 1 Aug 2025 09:38:17 +0530 Subject: [PATCH 08/11] compose [nfc]: Expose `uploadFiles` on `ComposeBoxState` This will be used soon to allow uploading files directly via using `MessageListPageState`. --- lib/widgets/compose_box.dart | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index bdfab54f94..c8fa7976ee 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -929,6 +929,7 @@ Future _uploadFiles({ required BuildContext context, required ComposeContentController contentController, required FocusNode contentFocusNode, + bool shouldRequestFocus = true, required Iterable files, }) async { assert(context.mounted); @@ -965,7 +966,7 @@ Future _uploadFiles({ zulipLocalizations); uploadsInProgress.add((tag, file)); } - if (!contentFocusNode.hasFocus) { + if (shouldRequestFocus && !contentFocusNode.hasFocus) { contentFocusNode.requestFocus(); } @@ -1869,6 +1870,24 @@ abstract class ComposeBoxState extends State { /// Switch the compose box back to regular non-edit mode, with no content. void endEditInteraction(); + + /// Uploads the provided files, populating the content input with their links. + /// + /// If any of the files are larger than maximum file size allowed by the + /// server, an error dialog is shown mentioning their names and actual + /// file sizes. + /// + /// While uploading, a placeholder link is inserted in the content input and + /// if [shouldRequestFocus] is true it will be focused. And then after + /// uploading completes successfully the placeholder link will be replaced + /// with an actual link. + /// + /// If there is an error while uploading a file, then an error dialog is + /// shown mentioning the corresponding file name. + Future uploadFiles({ + required Iterable files, + required bool shouldRequestFocus, + }); } class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { @@ -2021,6 +2040,19 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM }); } + @override + Future uploadFiles({ + required Iterable files, + required bool shouldRequestFocus, + }) async { + await _uploadFiles( + context: context, + contentController: controller.content, + contentFocusNode: controller.contentFocusNode, + shouldRequestFocus: shouldRequestFocus, + files: files); + } + @override void onNewStore() { final newStore = PerAccountStoreWidget.of(context); From 37f71c638e7cafa78a61b9afbfb2c6efd6a37240 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Sun, 3 Aug 2025 05:50:38 +0530 Subject: [PATCH 09/11] subscription_list [nfc]: Handle bottom insets explicitly in subscription list page This reverts part of 742320ce7 for SubscriptionListPageBody, as this widget is planned to be used outside the context of home page, specifically for the upcoming share page. --- lib/widgets/subscription_list.dart | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index de791ed4f7..33f69387d1 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -132,7 +132,18 @@ class _SubscriptionListPageBodyState extends State wit final onChannelSelect = widget.onChannelSelect ?? _handleChannelSelect; - return SafeArea( // horizontal insets + return SafeArea( + // Don't pad the bottom here; we want the list content to do that. + // + // When this page is used in the context of the home page, this + // param and the below use of `SliverSafeArea` would be noop, because + // `Scaffold.bottomNavigationBar` in the home page handles that for us. + // But this page is planned to be used for share-to-zulip page, so we + // need this to be handled here. + // + // Other *PageBody widgets don't handle this because they aren't + // planned to be (re-)used outside the context of the home page. + bottom: false, child: CustomScrollView( slivers: [ if (pinned.isNotEmpty) ...[ @@ -153,6 +164,11 @@ class _SubscriptionListPageBodyState extends State wit ], // TODO(#188): add button leading to "All Streams" page with ability to subscribe + + // This ensures last item in scrollable can settle in an unobstructed area. + // (Noop in the home-page case; see comment on `bottom: false` arg in + // use of `SafeArea` above.) + const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), ])); } } From 91b9335ccc181f23f1878cfba28c65ff6aa615b5 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 5 Aug 2025 19:44:11 +0530 Subject: [PATCH 10/11] recent dms [nfc]: Handle bottom insets explicitly in recent DMs page This reverts part of 742320ce7 for RecentDmConversationsPageBody, and also handles bottom insets for the new DMs button. As this widget is planned to be used outside the context of home page, specifically for the upcoming share page. --- lib/widgets/recent_dm_conversations.dart | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index c2e48aae1e..3856c0bf72 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -91,6 +91,11 @@ class _RecentDmConversationsPageBodyState extends State Date: Fri, 1 Aug 2025 10:14:34 +0530 Subject: [PATCH 11/11] share: Support sharing content received from other apps on Android Enables the app to receive arbitrary content from other apps via advertising Android Intent filters in AndroidManifest. It allows the OS to list our app in the platform share sheet. Adds handlers for the two Intent actions, namely SEND and SEND_MULTIPLE. Handling all three possible combinations: - Receiving only a text - Receiving only a file (or multiple files in case of SEND_MULTIPLE) - Receiving both the file (or multiple) and the accompanying text. The Android side Kotlin implementation is adapted from the legacy app's implementation, with only difference being that the legacy app didn't handle the 3rd case mentioned above, see: https://github.com/zulip/zulip-mobile/blob/eb8505c4a/android/app/src/main/java/com/zulipmobile/sharing/SharingHelper.kt To allow sending Android Intent events from Kotlin to Dart, Pigeon's EventChannelApi is used. For which the registration happens in `MainActivity.configureFlutterEngine`, this bit of code was adapted from Pigeon's Android example, see: https://github.com/flutter/packages/blob/b2aef15c1/packages/pigeon/example/app/android/app/src/main/kotlin/dev/flutter/pigeon_example_app/MainActivity.kt#L109-L121 --- android/app/src/main/AndroidManifest.xml | 10 + .../flutter/AndroidIntentEventListener.kt | 112 ++++++++++ .../com/zulip/flutter/AndroidIntents.g.kt | 203 +++++++++++++++++ .../kotlin/com/zulip/flutter/MainActivity.kt | 38 +++- assets/l10n/app_en.arb | 12 + lib/generated/l10n/zulip_localizations.dart | 18 ++ .../l10n/zulip_localizations_ar.dart | 10 + .../l10n/zulip_localizations_de.dart | 10 + .../l10n/zulip_localizations_en.dart | 10 + .../l10n/zulip_localizations_fr.dart | 10 + .../l10n/zulip_localizations_it.dart | 10 + .../l10n/zulip_localizations_ja.dart | 10 + .../l10n/zulip_localizations_nb.dart | 10 + .../l10n/zulip_localizations_pl.dart | 10 + .../l10n/zulip_localizations_ru.dart | 10 + .../l10n/zulip_localizations_sk.dart | 10 + .../l10n/zulip_localizations_sl.dart | 10 + .../l10n/zulip_localizations_uk.dart | 10 + .../l10n/zulip_localizations_zh.dart | 10 + lib/host/android_intents.dart | 1 + lib/host/android_intents.g.dart | 174 +++++++++++++++ lib/main.dart | 2 + lib/model/binding.dart | 6 + lib/widgets/recent_dm_conversations.dart | 11 +- lib/widgets/share.dart | 209 ++++++++++++++++++ lib/widgets/subscription_list.dart | 11 +- pigeon/android_intents.dart | 50 +++++ test/model/binding.dart | 5 + 28 files changed, 985 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt create mode 100644 android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt create mode 100644 lib/host/android_intents.dart create mode 100644 lib/host/android_intents.g.dart create mode 100644 lib/widgets/share.dart create mode 100644 pigeon/android_intents.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa2c342af5..2624d97b6a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,16 @@ + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt new file mode 100644 index 0000000000..9151f63da2 --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntentEventListener.kt @@ -0,0 +1,112 @@ +package com.zulip.flutter + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns + +class AndroidIntentEventListener : AndroidIntentEventsStreamHandler() { + private var eventSink: PigeonEventSink? = null + private val buffer = mutableListOf() + + override fun onListen(p0: Any?, sink: PigeonEventSink) { + eventSink = sink + buffer.forEach { eventSink!!.success(it) } + } + + private fun onEvent(event: AndroidIntentEvent) { + if (eventSink != null) { + eventSink?.success(event) + } else { + buffer.add(event) + } + } + + fun handleSend(context: Context, intent: Intent) { + val intentAction = intent.action + assert( + intentAction == Intent.ACTION_SEND + || intentAction == Intent.ACTION_SEND_MULTIPLE + ) + + // EXTRA_TEXT and EXTRA_STREAM are the text and file components of the + // content, respectively. The ACTION_SEND{,_MULTIPLE} docs say + // "either" / "or" will be present: + // https://developer.android.com/reference/android/content/Intent#ACTION_SEND + // But empirically both can be present, commonly, so we accept that form, + // interpreting it as an intent to share both kinds of data. + // + // Empirically, sometimes EXTRA_TEXT isn't something we think needs to be + // shared, like the URL of a file that's present in EXTRA_STREAM… but we + // shrug and include it anyway because we don't want to second-guess other + // apps' decisions about what to include; it's their responsibility. + + val extraText = intent.getStringExtra(Intent.EXTRA_TEXT) + val extraStream = when (intentAction) { + Intent.ACTION_SEND -> { + var extraStream: List? = null + // TODO(android-sdk-33) Remove the use of deprecated API. + @Suppress("DEPRECATION") val url = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if (url != null) { + extraStream = listOf(getIntentSharedFile(context, url)) + } + extraStream + } + + Intent.ACTION_SEND_MULTIPLE -> { + var extraStream: MutableList? = null + // TODO(android-sdk-33) Remove the use of deprecated API. + @Suppress("DEPRECATION") val urls = + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + if (urls != null) { + extraStream = mutableListOf() + for (url in urls) { + val sharedFile = getIntentSharedFile(context, url) + extraStream.add(sharedFile) + } + } + extraStream + } + + else -> throw IllegalArgumentException("Unexpected value for intent.action: $intentAction") + } + + if (extraText == null && extraStream == null) { + throw Exception("Got unexpected ACTION_SEND* intent, with neither EXTRA_TEXT nor EXTRA_STREAM") + } + + onEvent( + AndroidIntentSendEvent( + action = intentAction, + extraText = extraText, + extraStream = extraStream, + ) + ) + } +} + +// A helper function to retrieve the shared file from the `content://` URL +// from the ACTION_SEND{_MULTIPLE} intent. +fun getIntentSharedFile(context: Context, url: Uri): IntentSharedFile { + val contentResolver = context.contentResolver + val mimeType = contentResolver.getType(url) + val name = contentResolver.query(url, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.getString(nameIndex) + } ?: ("unknown." + (mimeType?.split('/')?.last() ?: "bin")) + + class ResolverFailedException(msg: String) : RuntimeException(msg) + + val bytes = (contentResolver.openInputStream(url) + ?: throw ResolverFailedException("resolver.open… failed")) + .use { inputStream -> + inputStream.readBytes() + } + + return IntentSharedFile( + name = name, + mimeType = mimeType, + bytes = bytes + ) +} diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt new file mode 100644 index 0000000000..7529e632c6 --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt @@ -0,0 +1,203 @@ +// Autogenerated from Pigeon (v25.5.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.zulip.flutter + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object AndroidIntentsPigeonUtils { + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class IntentSharedFile ( + val name: String, + val mimeType: String? = null, + val bytes: ByteArray +) + { + companion object { + fun fromList(pigeonVar_list: List): IntentSharedFile { + val name = pigeonVar_list[0] as String + val mimeType = pigeonVar_list[1] as String? + val bytes = pigeonVar_list[2] as ByteArray + return IntentSharedFile(name, mimeType, bytes) + } + } + fun toList(): List { + return listOf( + name, + mimeType, + bytes, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is IntentSharedFile) { + return false + } + if (this === other) { + return true + } + return AndroidIntentsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Generated class from Pigeon that represents data sent in messages. + * This class should not be extended by any user class outside of the generated file. + */ +sealed class AndroidIntentEvent +/** Generated class from Pigeon that represents data sent in messages. */ +data class AndroidIntentSendEvent ( + val action: String, + val extraText: String? = null, + val extraStream: List? = null +) : AndroidIntentEvent() + { + companion object { + fun fromList(pigeonVar_list: List): AndroidIntentSendEvent { + val action = pigeonVar_list[0] as String + val extraText = pigeonVar_list[1] as String? + val extraStream = pigeonVar_list[2] as List? + return AndroidIntentSendEvent(action, extraText, extraStream) + } + } + fun toList(): List { + return listOf( + action, + extraText, + extraStream, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AndroidIntentSendEvent) { + return false + } + if (this === other) { + return true + } + return AndroidIntentsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class AndroidIntentsPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + IntentSharedFile.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + AndroidIntentSendEvent.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is IntentSharedFile -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is AndroidIntentSendEvent -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +val AndroidIntentsPigeonMethodCodec = StandardMethodCodec(AndroidIntentsPigeonCodec()) + + +private class AndroidIntentsPigeonStreamHandler( + val wrapper: AndroidIntentsPigeonEventChannelWrapper +) : EventChannel.StreamHandler { + var pigeonSink: PigeonEventSink? = null + + override fun onListen(p0: Any?, sink: EventChannel.EventSink) { + pigeonSink = PigeonEventSink(sink) + wrapper.onListen(p0, pigeonSink!!) + } + + override fun onCancel(p0: Any?) { + pigeonSink = null + wrapper.onCancel(p0) + } +} + +interface AndroidIntentsPigeonEventChannelWrapper { + open fun onListen(p0: Any?, sink: PigeonEventSink) {} + + open fun onCancel(p0: Any?) {} +} + +class PigeonEventSink(private val sink: EventChannel.EventSink) { + fun success(value: T) { + sink.success(value) + } + + fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink.error(errorCode, errorMessage, errorDetails) + } + + fun endOfStream() { + sink.endOfStream() + } +} + +abstract class AndroidIntentEventsStreamHandler : AndroidIntentsPigeonEventChannelWrapper { + companion object { + fun register(messenger: BinaryMessenger, streamHandler: AndroidIntentEventsStreamHandler, instanceName: String = "") { + var channelName: String = "dev.flutter.pigeon.zulip.AndroidIntentsEventChannelApi.androidIntentEvents" + if (instanceName.isNotEmpty()) { + channelName += ".$instanceName" + } + val internalStreamHandler = AndroidIntentsPigeonStreamHandler(streamHandler) + EventChannel(messenger, channelName, AndroidIntentsPigeonMethodCodec).setStreamHandler(internalStreamHandler) + } + } +} + diff --git a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt index 1829456362..cad696eecf 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt @@ -1,6 +1,42 @@ package com.zulip.flutter +import android.content.Intent import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity: FlutterActivity() { +class MainActivity : FlutterActivity() { + private var androidIntentEventListener: AndroidIntentEventListener? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + androidIntentEventListener = AndroidIntentEventListener() + AndroidIntentEventsStreamHandler.register( + flutterEngine.dartExecutor.binaryMessenger, + androidIntentEventListener!! + ) + maybeHandleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + if (maybeHandleIntent(intent)) { + return + } + super.onNewIntent(intent) + } + + /** Returns true just if we did handle the intent. */ + private fun maybeHandleIntent(intent: Intent?): Boolean { + intent ?: return false + when (intent.action) { + // Share-to-Zulip + Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> { + androidIntentEventListener!!.handleSend(this, intent) + return true + } + + // For other intents, let Flutter handle it. + else -> return false + } + } } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 77200c01eb..8d6328faef 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -995,6 +995,10 @@ "@channelsPageTitle": { "description": "Title for the page with a list of subscribed channels." }, + "sharePageTitle": "Share", + "@sharePageTitle": { + "description": "Title for the page about sharing content received from other apps." + }, "channelsEmptyPlaceholder": "You are not subscribed to any channels yet.", "@channelsEmptyPlaceholder": { "description": "Centered text on the 'Channels' page saying that there is no content to show." @@ -1224,6 +1228,14 @@ "@errorReactionRemovingFailedTitle": { "description": "Error title when removing a message reaction fails" }, + "errorSharingTitle": "Failed to share content", + "@errorSharingTitle": { + "description": "Error title when sharing content received from other apps fails" + }, + "errorSharingAccountNotLoggedIn": "There is no account logged in. Please log in to an account and try again.", + "@errorSharingAccountNotLoggedIn": { + "description": "Error title when sharing content received from other apps fails, when there is no account logged in" + }, "emojiReactionsMore": "more", "@emojiReactionsMore": { "description": "Label for a button opening the emoji picker." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 8165cd0701..0035b3b557 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1487,6 +1487,12 @@ abstract class ZulipLocalizations { /// **'Channels'** String get channelsPageTitle; + /// Title for the page about sharing content received from other apps. + /// + /// In en, this message translates to: + /// **'Share'** + String get sharePageTitle; + /// Centered text on the 'Channels' page saying that there is no content to show. /// /// In en, this message translates to: @@ -1799,6 +1805,18 @@ abstract class ZulipLocalizations { /// **'Removing reaction failed'** String get errorReactionRemovingFailedTitle; + /// Error title when sharing content received from other apps fails + /// + /// In en, this message translates to: + /// **'Failed to share content'** + String get errorSharingTitle; + + /// Error title when sharing content received from other apps fails, when there is no account logged in + /// + /// In en, this message translates to: + /// **'There is no account logged in. Please log in to an account and try again.'** + String get errorSharingAccountNotLoggedIn; + /// Label for a button opening the emoji picker. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 5ff9981001..968bdfa514 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 8d0826bac9..de07e7874b 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -832,6 +832,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanäle'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Du hast noch keine Kanäle abonniert.'; @@ -1026,6 +1029,13 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Entfernen der Reaktion fehlgeschlagen'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'mehr'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index bffdb9f9a9..ec641b7797 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 5001c79eed..32ef7b72c4 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 25a5c4e999..8149552491 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -826,6 +826,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get channelsPageTitle => 'Canali'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Non sei ancora iscritto ad alcun canale.'; @@ -1021,6 +1024,13 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Rimozione della reazione non riuscita'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'altro'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index e39ebe4377..ca2eee8cb3 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -808,6 +808,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -996,6 +999,13 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d08ca0eaf0..52e6e335f2 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 9b856a6aae..bd04f2db82 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -822,6 +822,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanały'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Nie śledzisz żadnego z kanałów.'; @@ -1012,6 +1015,13 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Usuwanie reakcji bez powodzenia'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'więcej'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 848b02eefb..c7506d8ce9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -825,6 +825,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelsPageTitle => 'Каналы'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Вы еще не подписаны ни на один канал.'; @@ -1016,6 +1019,13 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Не удалось удалить реакцию'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'еще'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 96e9e0c542..82bbfb077a 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -813,6 +813,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanály'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -1001,6 +1004,13 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Odobranie reakcie zlyhalo'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'viac'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index bdb56cf44d..e9cf3035f4 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -837,6 +837,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get channelsPageTitle => 'Kanali'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Niste še naročeni na noben kanal.'; @@ -1028,6 +1031,13 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get errorReactionRemovingFailedTitle => 'Reakcije ni bilo mogoče odstraniti'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'več'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 0c2f49363e..9ff5bbd8cf 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -825,6 +825,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get channelsPageTitle => 'Канали'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'Ви ще не підписані на жодний канал.'; @@ -1016,6 +1019,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Не вдалося видалити реакцію'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'більше'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index fdfd2966b5..a19ca6ebfb 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -811,6 +811,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get channelsPageTitle => 'Channels'; + @override + String get sharePageTitle => 'Share'; + @override String get channelsEmptyPlaceholder => 'You are not subscribed to any channels yet.'; @@ -999,6 +1002,13 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + @override + String get errorSharingTitle => 'Failed to share content'; + + @override + String get errorSharingAccountNotLoggedIn => + 'There is no account logged in. Please log in to an account and try again.'; + @override String get emojiReactionsMore => 'more'; diff --git a/lib/host/android_intents.dart b/lib/host/android_intents.dart new file mode 100644 index 0000000000..6bd1e60de5 --- /dev/null +++ b/lib/host/android_intents.dart @@ -0,0 +1 @@ +export 'android_intents.g.dart'; diff --git a/lib/host/android_intents.g.dart b/lib/host/android_intents.g.dart new file mode 100644 index 0000000000..1e0b6e5e1a --- /dev/null +++ b/lib/host/android_intents.g.dart @@ -0,0 +1,174 @@ +// Autogenerated from Pigeon (v25.5.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +class IntentSharedFile { + IntentSharedFile({ + required this.name, + this.mimeType, + required this.bytes, + }); + + String name; + + String? mimeType; + + Uint8List bytes; + + List _toList() { + return [ + name, + mimeType, + bytes, + ]; + } + + Object encode() { + return _toList(); } + + static IntentSharedFile decode(Object result) { + result as List; + return IntentSharedFile( + name: result[0]! as String, + mimeType: result[1] as String?, + bytes: result[2]! as Uint8List, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! IntentSharedFile || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +sealed class AndroidIntentEvent { +} + +class AndroidIntentSendEvent extends AndroidIntentEvent { + AndroidIntentSendEvent({ + required this.action, + this.extraText, + this.extraStream, + }); + + String action; + + String? extraText; + + List? extraStream; + + List _toList() { + return [ + action, + extraText, + extraStream, + ]; + } + + Object encode() { + return _toList(); } + + static AndroidIntentSendEvent decode(Object result) { + result as List; + return AndroidIntentSendEvent( + action: result[0]! as String, + extraText: result[1] as String?, + extraStream: (result[2] as List?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AndroidIntentSendEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is IntentSharedFile) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is AndroidIntentSendEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return IntentSharedFile.decode(readValue(buffer)!); + case 130: + return AndroidIntentSendEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + +Stream androidIntentEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel androidIntentEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.AndroidIntentsEventChannelApi.androidIntentEvents$instanceName', pigeonMethodCodec); + return androidIntentEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as AndroidIntentEvent; + }); +} + diff --git a/lib/main.dart b/lib/main.dart index f89ccb6040..6c23bca66a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'log.dart'; import 'model/binding.dart'; import 'notifications/receive.dart'; import 'widgets/app.dart'; +import 'widgets/share.dart'; void main() { assert(() { @@ -16,5 +17,6 @@ void main() { WidgetsFlutterBinding.ensureInitialized(); LiveZulipBinding.ensureInitialized(); NotificationService.instance.start(); + ShareService.start(); runApp(const ZulipApp()); } diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 2ad21d939b..2ef003fc0f 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -10,6 +10,7 @@ import 'package:package_info_plus/package_info_plus.dart' as package_info_plus; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; +import '../host/android_intents.dart' as android_intents_pigeon; import '../host/android_notifications.dart'; import '../host/notifications.dart' as notif_pigeon; import '../log.dart'; @@ -183,6 +184,8 @@ abstract class ZulipBinding { /// Wraps the [notif_pigeon.NotificationHostApi] class. NotificationPigeonApi get notificationPigeonApi; + Stream get androidIntentEvents; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -488,6 +491,9 @@ class LiveZulipBinding extends ZulipBinding { @override NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi(); + @override + Stream get androidIntentEvents => android_intents_pigeon.androidIntentEvents(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 3856c0bf72..b287a084b2 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -23,6 +23,11 @@ class RecentDmConversationsPageBody extends StatefulWidget { this.onDmSelect, }); + // TODO refactor this widget to avoid reuse of the whole page, + // avoiding the need for these flags, callback, and the below + // handling of safe-area at this level of abstraction. + // See discussion: + // https://github.com/zulip/zulip-flutter/pull/1774#discussion_r2249032503 final bool hideDmsIfUserCantPost; /// Callback to invoke when the user selects a DM conversation from the list. @@ -111,11 +116,11 @@ class _RecentDmConversationsPageBodyState extends State start() async { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + ZulipBinding.instance.androidIntentEvents.listen((event) { + switch (event) { + case AndroidIntentSendEvent(): + _handleSend(event); + } + }); + + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't support receiving shared content from + // other apps on these platforms. + break; + } + } + + static Future _handleSend(AndroidIntentSendEvent intentSendEvent) async { + assert(defaultTargetPlatform == TargetPlatform.android); + + assert(debugLog('intentSendEvent.action: ${intentSendEvent.action}')); + assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}')); + assert(debugLog('intentSendEvent.extraStream: [${intentSendEvent.extraStream?.join(',')}]')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final globalStore = GlobalStoreWidget.of(context); + + // TODO(#524) choose initial account as last one used + // TODO(#1779) allow selecting account, if there are multiple + final initialAccountId = globalStore.accounts.firstOrNull?.id; + + if (initialAccountId == null) { + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog( + context: context, + title: zulipLocalizations.errorSharingTitle, + message: zulipLocalizations.errorSharingAccountNotLoggedIn); + return; + } + + unawaited(navigator.push( + SharePage.buildRoute( + accountId: initialAccountId, + sharedFiles: intentSendEvent.extraStream?.map((sharedFile) { + var mimeType = sharedFile.mimeType; + + // Try to guess the mimeType from file header magic-number. + mimeType ??= lookupMimeType( + // Seems like the path shouldn't be required; we still want to look for + // matches on `headerBytes`. Thankfully we can still do that, by calling + // lookupMimeType with the empty string as the path. That's a value that + // doesn't map to any particular type, so the path will be effectively + // ignored, as desired. Upstream comment: + // https://github.com/dart-lang/mime/issues/11#issuecomment-2246824452 + '', + headerBytes: List.unmodifiable( + sharedFile.bytes.take(defaultMagicNumbersMaxLength))); + + return FileToUpload( + content: Stream.value(sharedFile.bytes), + length: sharedFile.bytes.length, + filename: sharedFile.name, + mimeType: mimeType); + }), + sharedText: intentSendEvent.extraText))); + } +} + +class SharePage extends StatelessWidget { + const SharePage({ + super.key, + required this.sharedFiles, + required this.sharedText, + }); + + final Iterable? sharedFiles; + final String? sharedText; + + static AccountRoute buildRoute({ + required int accountId, + required Iterable? sharedFiles, + required String? sharedText, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId, + page: SharePage( + sharedFiles: sharedFiles, + sharedText: sharedText)); + } + + void _handleNarrowSelect(BuildContext context, Narrow narrow) { + final messageListPageStateKey = GlobalKey(); + + // Push the message list page, replacing the share page. + unawaited(Navigator.pushReplacement(context, + MessageListPage.buildRoute( + key: messageListPageStateKey, + context: context, + narrow: narrow))); + + // Wait for the message list page to accommodate in the widget tree from the route. + SchedulerBinding.instance.addPostFrameCallback((_) async { + final messageListPageState = messageListPageStateKey.currentState; + if (messageListPageState == null) return; // TODO(log) + final composeBoxState = messageListPageState.composeBoxState; + if (composeBoxState == null) return; // TODO(log) + + final composeBoxController = composeBoxState.controller; + + // Focus on the topic input if there is one, else focus on content + // input, if not already focused. + composeBoxController.requestFocusIfUnfocused(); + + // We can receive both: the file/s and an accompanying text, + // so first populate the compose box with the text, if there is any. + if (sharedText case var text?) { + if (!text.endsWith('\n')) text += '\n'; + + // If there are any shared files, add a separator new line. + if (sharedFiles != null) text += '\n'; + + // Populate the content input with this text. + final contentController = composeBoxController.content; + contentController.value = + contentController.value + .replaced(contentController.insertionIndex(), text); + } + // Then upload the files and populate the content input with their links. + if (sharedFiles != null) { + await composeBoxState.uploadFiles( + files: sharedFiles!, + // We handle requesting focus ourselves above. + shouldRequestFocus: false); + } + }); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text(zulipLocalizations.sharePageTitle), + bottom: TabBar( + indicatorColor: designVariables.icon, + labelColor: designVariables.foreground, + unselectedLabelColor: designVariables.foreground.withFadedAlpha(0.7), + splashFactory: NoSplash.splashFactory, + tabs: [ + Tab(text: zulipLocalizations.channelsPageTitle), + Tab(text: zulipLocalizations.recentDmConversationsPageTitle), + ])), + body: TabBarView(children: [ + SubscriptionListPageBody( + showTopicListButtonInActionSheet: false, + hideChannelsIfUserCantPost: true, + onChannelSelect: (narrow) => _handleNarrowSelect(context, narrow), + // TODO(#412) add onTopicSelect, Currently when user lands on the + // channel feed page from subscription list page and they tap + // on the topic recipient header, the user is brought to the + // topic message list, but without the share content. So, we + // might want to force the user to choose a topic or start a + // new topic from the subscription list page. + ), + RecentDmConversationsPageBody( + hideDmsIfUserCantPost: true, + onDmSelect: (narrow) => _handleNarrowSelect(context, narrow)), + ]))); + } +} diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 33f69387d1..77fecb6c68 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -24,6 +24,11 @@ class SubscriptionListPageBody extends StatefulWidget { this.onChannelSelect, }); + // TODO refactor this widget to avoid reuse of the whole page, + // avoiding the need for these flags, callback(s), and the below + // handling of safe-area at this level of abstraction. + // See discussion: + // https://github.com/zulip/zulip-flutter/pull/1774#discussion_r2249032503 final bool showTopicListButtonInActionSheet; final bool hideChannelsIfUserCantPost; @@ -138,11 +143,11 @@ class _SubscriptionListPageBodyState extends State wit // When this page is used in the context of the home page, this // param and the below use of `SliverSafeArea` would be noop, because // `Scaffold.bottomNavigationBar` in the home page handles that for us. - // But this page is planned to be used for share-to-zulip page, so we - // need this to be handled here. + // But this page is also used for share-to-zulip page, so we need this + // to be handled here. // // Other *PageBody widgets don't handle this because they aren't - // planned to be (re-)used outside the context of the home page. + // (re-)used outside the context of the home page. bottom: false, child: CustomScrollView( slivers: [ diff --git a/pigeon/android_intents.dart b/pigeon/android_intents.dart new file mode 100644 index 0000000000..2d668da518 --- /dev/null +++ b/pigeon/android_intents.dart @@ -0,0 +1,50 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_intents.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidIntents.g.kt', + kotlinOptions: KotlinOptions( + package: 'com.zulip.flutter', + // One error class is already generated in AndroidNotifications.g.kt , + // so avoid generating another one, preventing duplicate classes under + // the same namespace. + includeErrorClass: false))) + +// TODO separate out API calls for resolving file name, getting mimetype, getting bytes? +class IntentSharedFile { + const IntentSharedFile({ + required this.name, + required this.mimeType, + required this.bytes, + }); + + final String name; + final String? mimeType; + final Uint8List bytes; +} + +sealed class AndroidIntentEvent { + const AndroidIntentEvent(); + + // Pigeon doesn't seem to allow fields in sealed classes. + // final String action; +} + +class AndroidIntentSendEvent extends AndroidIntentEvent { + const AndroidIntentSendEvent({ + required this.action, // 'android.intent.action.SEND' or 'android.intent.action.SEND_MULTIPLE' + required this.extraText, + required this.extraStream, + }); + + final String action; + final String? extraText; + final List? extraStream; +} + +@EventChannelApi() +abstract class AndroidIntentsEventChannelApi { + AndroidIntentEvent androidIntentEvents(); +} diff --git a/test/model/binding.dart b/test/model/binding.dart index ca9f43603f..c7fe2bf639 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; +import 'package:zulip/host/android_intents.dart'; import 'package:zulip/host/android_notifications.dart'; import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/binding.dart'; @@ -419,6 +420,10 @@ class TestZulipBinding extends ZulipBinding { Future toggleWakelock({required bool enable}) async { _wakelockEnabled = enable; } + + @override + // TODO(#1787) implement androidIntentEvents and write related tests + Stream get androidIntentEvents => throw UnimplementedError(); } class FakeFirebaseMessaging extends Fake implements FirebaseMessaging {