diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1d529f95f0..2520b6bb81 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -180,6 +180,10 @@ "@actionSheetOptionMarkTopicAsRead": { "description": "Option to mark a specific topic as read in the action sheet." }, + "actionSheetOptionCopyTopicLink": "Copy link to topic", + "@actionSheetOptionCopyTopicLink": { + "description": "Label for copy topic link button in action sheet." + }, "errorWebAuthOperationalErrorTitle": "Something went wrong", "@errorWebAuthOperationalErrorTitle": { "description": "Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials)." @@ -361,6 +365,10 @@ "@successMessageLinkCopied": { "description": "Message when link of a message was copied to the user's system clipboard." }, + "successTopicLinkCopied": "Topic link copied", + "@successTopicLinkCopied": { + "description": "Message when link of a topic was copied to the user's system clipboard." + }, "successChannelLinkCopied": "Channel link copied", "@successChannelLinkCopied": { "description": "Message when link of a channel was copied to the user's system clipboard." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 907e29d538..f1bc2e1e8f 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -401,6 +401,12 @@ abstract class ZulipLocalizations { /// **'Mark topic as read'** String get actionSheetOptionMarkTopicAsRead; + /// Label for copy topic link button in action sheet. + /// + /// In en, this message translates to: + /// **'Copy link to topic'** + String get actionSheetOptionCopyTopicLink; + /// Error title when third-party authentication has an operational error (not necessarily caused by invalid credentials). /// /// In en, this message translates to: @@ -625,6 +631,12 @@ abstract class ZulipLocalizations { /// **'Message link copied'** String get successMessageLinkCopied; + /// Message when link of a topic was copied to the user's system clipboard. + /// + /// In en, this message translates to: + /// **'Topic link copied'** + String get successTopicLinkCopied; + /// Message when link of a channel was copied to the user's system clipboard. /// /// 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 96cafc3cc9..3784045f0b 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -151,6 +151,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -303,6 +306,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Message link copied'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index a016576322..16637d166c 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -155,6 +155,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Thema als gelesen markieren'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Etwas ist schiefgelaufen'; @@ -316,6 +319,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Nachrichtenlink kopiert'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index fa76f8decc..8ab4638c78 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -151,6 +151,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -303,6 +306,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Message link copied'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index a0cff72bff..7cb32f40e1 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -151,6 +151,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -303,6 +306,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Message link copied'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index b93ddcdb0e..8716612d8b 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -156,6 +156,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get actionSheetOptionMarkTopicAsRead => 'Segna l\'argomento come letto'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Qualcosa è andato storto'; @@ -313,6 +316,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Collegamento messaggio copiato'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 9e5eba47f8..234cfb22c4 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -149,6 +149,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'トピックを既読にする'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => '問題が発生しました'; @@ -300,6 +303,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Message link copied'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 73b5a00f0e..46771b771f 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -151,6 +151,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -303,6 +306,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Message link copied'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 2861c82c8c..7714cd26bc 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -158,6 +158,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionMarkTopicAsRead => 'Oznacz wątek jako przeczytany'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Coś poszło nie tak'; @@ -311,6 +314,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Skopiowano odnośnik wiadomości'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 5fdf3c879c..f7e35040a1 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -158,6 +158,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkTopicAsRead => 'Отметить тему как прочитанную'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Что-то пошло не так'; @@ -312,6 +315,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Ссылка на сообщение скопирована'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index e79d81866f..52f0d3794b 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -152,6 +152,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Niečo sa pokazilo'; @@ -303,6 +306,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Message link copied'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index df7ba7377e..91c7a3070a 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -155,6 +155,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Označi temo kot prebrano'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Nekaj je šlo narobe'; @@ -323,6 +326,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get successMessageLinkCopied => 'Povezava do sporočila je bila kopirana'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 169af2fa7e..289ebe6de6 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -158,6 +158,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Позначити тему як прочитану'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Щось пішло не так'; @@ -314,6 +317,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get successMessageLinkCopied => 'Посилання на повідомлення скопійовано'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 4b9bc3759b..5be4fdf1fb 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -151,6 +151,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get actionSheetOptionMarkTopicAsRead => 'Mark topic as read'; + @override + String get actionSheetOptionCopyTopicLink => 'Copy link to topic'; + @override String get errorWebAuthOperationalErrorTitle => 'Something went wrong'; @@ -303,6 +306,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get successMessageLinkCopied => 'Message link copied'; + @override + String get successTopicLinkCopied => 'Topic link copied'; + @override String get successChannelLinkCopied => 'Channel link copied'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index b77c73a55d..11d7bc0fca 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -389,15 +389,9 @@ void showTopicActionSheet(BuildContext context, { pageContext: context)); } - if (optionButtons.isEmpty) { - // TODO(a11y): This case makes a no-op gesture handler; as a consequence, - // we're presenting some UI (to people who use screen-reader software) as - // though it offers a gesture interaction that it doesn't meaningfully - // offer, which is confusing. The solution here is probably to remove this - // is-empty case by having at least one button that's always present, - // such as "copy link to topic". - return; - } + optionButtons.add(CopyTopicLinkButton( + narrow: TopicNarrow(channelId, topic, with_: someMessageIdInTopic), + pageContext: context)); _showActionSheet(pageContext, optionButtons: optionButtons); } @@ -618,6 +612,32 @@ class MarkTopicAsReadButton extends ActionSheetMenuItemButton { } } +class CopyTopicLinkButton extends ActionSheetMenuItemButton { + const CopyTopicLinkButton({ + super.key, + required this.narrow, + required super.pageContext, + }); + + final TopicNarrow narrow; + + @override IconData get icon => ZulipIcons.link; + + @override + String label(ZulipLocalizations localizations) { + return localizations.actionSheetOptionCopyTopicLink; + } + + @override void onPressed() async { + final localizations = ZulipLocalizations.of(pageContext); + final store = PerAccountStoreWidget.of(pageContext); + + PlatformActions.copyWithPopup(context: pageContext, + successContent: Text(localizations.successTopicLinkCopied), + data: ClipboardData(text: narrowLink(store, narrow).toString())); + } +} + /// Show a sheet of actions you can take on a message in the message list. /// /// Must have a [MessageListPage] ancestor. diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index e3b1d69d6e..fa6c8b9229 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -584,7 +584,7 @@ class MessageListAppBarTitle extends StatelessWidget { behavior: HitTestBehavior.translucent, onLongPress: () { final someMessage = MessageListPage.ancestorOf(context) - .model?.messages.firstOrNull; + .model?.messages.lastOrNull; // If someMessage is null, the topic action sheet won't have a // resolve/unresolve button. That seems OK; in that case we're // either still fetching messages (and the user can reopen the diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 28faa69ff6..561c136473 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -493,6 +493,7 @@ void main() { checkButton('Follow topic'); checkButton('Mark as resolved'); + checkButton('Copy link to topic'); } testWidgets('show from inbox; message in Unreads but not in MessageStore', (tester) async { @@ -888,6 +889,53 @@ void main() { ..bodyFields['flag'].equals('read'); }); }); + + group('CopyTopicLinkButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + Future tapCopyTopicLinkButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.link, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.link)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('copies topic link to clipboard', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: someTopic); + await prepare(channel: someChannel, topic: someTopic, + zulipFeatureLevel: eg.recentZulipFeatureLevel); + await showFromAppBar(tester, channel: someChannel, + topic: TopicName(someTopic), messages: [message]); + + await tapCopyTopicLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, + TopicNarrow(someChannel.streamId, TopicName(someTopic), with_: message.id)); + check(expectedLink.toString().contains('/with/')).isTrue(); + check((await Clipboard.getData('text/plain'))!) + .text.equals(expectedLink.toString()); + }); + + testWidgets('FL < 271 -> link doesn\'t contain "with" operator', (tester) async { + final message = eg.streamMessage(stream: someChannel, topic: someTopic); + await prepare(channel: someChannel, topic: someTopic, + zulipFeatureLevel: 270); + await showFromAppBar(tester, channel: someChannel, + topic: TopicName(someTopic), messages: [message]); + + await tapCopyTopicLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, + TopicNarrow(someChannel.streamId, TopicName(someTopic))); + check(expectedLink.toString().contains('/with/')).isFalse(); + check((await Clipboard.getData('text/plain'))!) + .text.equals(expectedLink.toString()); + }); + }); }); group('message action sheet', () {