From cc58c973411d8556fbad695105cf80e28dfa5339 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Thu, 26 Sep 2024 13:07:18 -0400 Subject: [PATCH] Add support for alternate link sources (#1516) * Support alternative link sources * Alternative -> Alternate * Ensure alternate navigation occurs within same webview --- lib/l10n/app_en.arb | 4 + lib/post/pages/legacy_post_page.dart | 15 ++ lib/post/widgets/post_page_app_bar.dart | 15 ++ lib/shared/common_markdown_body.dart | 2 +- lib/shared/link_information.dart | 2 +- lib/shared/link_preview_card.dart | 4 +- lib/shared/webview.dart | 14 ++ lib/utils/links.dart | 227 ++++++++++++++++++------ 8 files changed, 221 insertions(+), 62 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d9e9f79bd..c5bbe0fa4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -101,6 +101,10 @@ }, "alreadyPostedTo": "Already posted to", "@alreadyPostedTo": {}, + "alternateSources": "Alternate Sources", + "@alternateSources": { + "description": "Action for viewing a link from alternate sources" + }, "always": "Always", "@always": {}, "andXMore": "and {count} more", diff --git a/lib/post/pages/legacy_post_page.dart b/lib/post/pages/legacy_post_page.dart index 3dc63eb7c..af4bdd72b 100644 --- a/lib/post/pages/legacy_post_page.dart +++ b/lib/post/pages/legacy_post_page.dart @@ -16,6 +16,7 @@ import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:thunder/comment/utils/navigate_comment.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/enums/fab_action.dart'; +import 'package:thunder/core/enums/media_type.dart'; import 'package:thunder/core/models/comment_view_tree.dart'; import 'package:thunder/core/models/post_view_media.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; @@ -31,6 +32,7 @@ import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/shared/text/selectable_text_modal.dart'; import 'package:thunder/shared/thunder_popup_menu_item.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/utils/links.dart'; @Deprecated('Use the new PostPage widget') class PostPage extends StatefulWidget { @@ -231,6 +233,19 @@ class _PostPageState extends State { icon: Icons.select_all_rounded, title: l10n.selectText, ), + if (state.postView?.media.first.mediaType == MediaType.link && state.postView!.media.first.originalUrl?.isNotEmpty == true) + ThunderPopupMenuItem( + onTap: () { + handleLinkLongPress( + context, + state.postView!.media.first.originalUrl!, + state.postView!.media.first.originalUrl!, + initialPage: LinkBottomSheetPage.alternateLinks, + ); + }, + icon: Icons.link_rounded, + title: l10n.alternateSources, + ), ], ), ], diff --git a/lib/post/widgets/post_page_app_bar.dart b/lib/post/widgets/post_page_app_bar.dart index c0b9bdfc5..b2c50b6f7 100644 --- a/lib/post/widgets/post_page_app_bar.dart +++ b/lib/post/widgets/post_page_app_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:thunder/core/enums/media_type.dart'; import 'package:thunder/core/models/post_view_media.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; @@ -12,6 +13,7 @@ import 'package:thunder/shared/sort_picker.dart'; import 'package:thunder/shared/thunder_popup_menu_item.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/user/widgets/user_selector.dart'; +import 'package:thunder/utils/links.dart'; /// Holds the app bar for the post page. class PostPageAppBar extends StatelessWidget { @@ -201,6 +203,19 @@ class PostAppBarActions extends StatelessWidget { icon: Icons.people_alt_rounded, title: l10n.viewPostAsDifferentAccount, ), + if (state.postView?.media.first.mediaType == MediaType.link && state.postView!.media.first.originalUrl?.isNotEmpty == true) + ThunderPopupMenuItem( + onTap: () { + handleLinkLongPress( + context, + state.postView!.media.first.originalUrl!, + state.postView!.media.first.originalUrl!, + initialPage: LinkBottomSheetPage.alternateLinks, + ); + }, + icon: Icons.link_rounded, + title: l10n.alternateSources, + ), ], ), ], diff --git a/lib/shared/common_markdown_body.dart b/lib/shared/common_markdown_body.dart index 25e0ee6ca..2139a75f7 100644 --- a/lib/shared/common_markdown_body.dart +++ b/lib/shared/common_markdown_body.dart @@ -158,7 +158,7 @@ class CommonMarkdownBody extends StatelessWidget { ); }, onTapLink: (text, url, title) => handleLinkTap(context, state, text, url), - onLongPressLink: (text, url, title) => handleLinkLongPress(context, state, text, url), + onLongPressLink: (text, url, title) => handleLinkLongPress(context, text, url), styleSheet: hideContent ? spoilerMarkdownStyleSheet : MarkdownStyleSheet.fromTheme(theme).copyWith( diff --git a/lib/shared/link_information.dart b/lib/shared/link_information.dart index a01a9cb32..727a370e6 100644 --- a/lib/shared/link_information.dart +++ b/lib/shared/link_information.dart @@ -73,7 +73,7 @@ class _LinkInformationState extends State { } if (widget.mediaType == MediaType.link) { - handleLinkLongPress(context, state, widget.originURL!, widget.originURL); + handleLinkLongPress(context, widget.originURL!, widget.originURL); } }, child: Container( diff --git a/lib/shared/link_preview_card.dart b/lib/shared/link_preview_card.dart index cdc27a645..62e88a8b5 100644 --- a/lib/shared/link_preview_card.dart +++ b/lib/shared/link_preview_card.dart @@ -138,7 +138,7 @@ class LinkPreviewCard extends StatelessWidget { child: InkWell( splashColor: theme.colorScheme.primary.withOpacity(0.4), onTap: () => triggerOnTap(context), - onLongPress: originURL != null ? () => handleLinkLongPress(context, thunderState, originURL!, originURL) : null, + onLongPress: originURL != null ? () => handleLinkLongPress(context, originURL!, originURL) : null, borderRadius: BorderRadius.circular((edgeToEdgeImages ? 0 : 12)), ), ), @@ -226,7 +226,7 @@ class LinkPreviewCard extends StatelessWidget { child: InkWell( splashColor: theme.colorScheme.primary.withOpacity(0.4), onTap: () => triggerOnTap(context), - onLongPress: originURL != null ? () => handleLinkLongPress(context, thunderState, originURL!, originURL) : null, + onLongPress: originURL != null ? () => handleLinkLongPress(context, originURL!, originURL) : null, ), ), ), diff --git a/lib/shared/webview.dart b/lib/shared/webview.dart index bfd5c7731..377aaf30d 100644 --- a/lib/shared/webview.dart +++ b/lib/shared/webview.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; import 'package:thunder/shared/thunder_popup_menu_item.dart'; +import 'package:thunder/utils/links.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -162,6 +163,19 @@ class NavigationControls extends StatelessWidget { icon: Icons.share_rounded, title: l10n.share, ), + ThunderPopupMenuItem( + onTap: () { + handleLinkLongPress( + context, + url, + url, + initialPage: LinkBottomSheetPage.alternateLinks, + customNavigation: (url) => webViewController.loadRequest(Uri.parse(url)), + ); + }, + icon: Icons.link_rounded, + title: l10n.alternateSources, + ), ], ), const SizedBox(width: 8.0), diff --git a/lib/utils/links.dart b/lib/utils/links.dart index 3f9a008e2..da27461d2 100644 --- a/lib/utils/links.dart +++ b/lib/utils/links.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:html/parser.dart' as parser; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/message_format.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:link_preview_generator/link_preview_generator.dart'; import 'package:share_plus/share_plus.dart'; @@ -15,8 +16,8 @@ import 'package:thunder/feed/bloc/feed_bloc.dart'; import 'package:thunder/instances.dart'; import 'package:thunder/modlog/utils/navigate_modlog.dart'; import 'package:thunder/shared/pages/loading_page.dart'; +import 'package:thunder/shared/picker_item.dart'; import 'package:thunder/shared/webview.dart'; -import 'package:thunder/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/utils/media/image.dart'; import 'package:thunder/utils/media/video.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; @@ -309,80 +310,178 @@ Future _testValidUser(BuildContext context, String link, String userName, return false; } -void handleLinkLongPress(BuildContext context, ThunderState state, String text, String? url) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - +void handleLinkLongPress(BuildContext context, String text, String? url, {LinkBottomSheetPage initialPage = LinkBottomSheetPage.general, void Function(String)? customNavigation}) { HapticFeedback.mediumImpact(); showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, - builder: (ctx) { - bool isValidUrl = url?.startsWith('http') ?? false; + builder: (ctx) => LinkBottomSheet( + text: text, + url: url, + initialPage: initialPage, + customNavigation: customNavigation, + ), + ); +} + +enum LinkBottomSheetPage { + general, + alternateLinks, +} + +class LinkBottomSheet extends StatefulWidget { + final String? url; + final String text; + final LinkBottomSheetPage initialPage; + final void Function(String)? customNavigation; + + const LinkBottomSheet({ + super.key, + required this.text, + required this.url, + this.initialPage = LinkBottomSheetPage.general, + this.customNavigation, + }); + + @override + State createState() => _LinkBottomSheetState(); +} + +class _LinkBottomSheetState extends State { + LinkBottomSheetPage? page; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppLocalizations l10n = AppLocalizations.of(context)!; + final ThunderState thunderState = context.read().state; - return AnimatedSize( + bool isValidUrl = widget.url?.startsWith('http') ?? false; + + return SingleChildScrollView( + child: AnimatedSize( duration: const Duration(milliseconds: 250), alignment: Alignment.bottomCenter, - child: BottomSheetListPicker( - title: l10n.linkActions, - heading: Column( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, children: [ - if (isValidUrl) ...[ - LinkPreviewGenerator( - link: url!, - placeholderWidget: const CircularProgressIndicator(), - linkPreviewStyle: LinkPreviewStyle.large, - cacheDuration: Duration.zero, - onTap: null, - bodyTextOverflow: TextOverflow.fade, - graphicFit: BoxFit.scaleDown, - removeElevation: true, - backgroundColor: theme.dividerColor.withOpacity(0.25), - borderRadius: 10, - useDefaultOnTap: false, + Padding( + padding: const EdgeInsets.only(left: 10, right: 10), + child: Material( + borderRadius: BorderRadius.circular(50), + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(50), + onTap: (page ?? widget.initialPage) == LinkBottomSheetPage.general ? null : () => setState(() => page = LinkBottomSheetPage.general), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + if ((page ?? widget.initialPage) != LinkBottomSheetPage.general) ...[ + const Icon(Icons.chevron_left, size: 30), + const SizedBox(width: 12), + ], + Text( + switch (page ?? widget.initialPage) { + LinkBottomSheetPage.alternateLinks => l10n.alternateSources, + _ => l10n.linkActions, + }, + style: theme.textTheme.titleLarge, + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 10), + if (isValidUrl && (page ?? widget.initialPage) == LinkBottomSheetPage.general) ...[ + Padding( + padding: const EdgeInsets.only(left: 24, right: 24), + child: LinkPreviewGenerator( + link: widget.url!, + placeholderWidget: const CircularProgressIndicator(), + linkPreviewStyle: LinkPreviewStyle.large, + cacheDuration: Duration.zero, + onTap: null, + bodyTextOverflow: TextOverflow.fade, + graphicFit: BoxFit.scaleDown, + removeElevation: true, + backgroundColor: theme.dividerColor.withOpacity(0.25), + borderRadius: 10, + useDefaultOnTap: false, + ), ), const SizedBox(height: 10), ], - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - decoration: BoxDecoration( - color: theme.dividerColor.withOpacity(0.25), - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(url!), - ), + Padding( + padding: const EdgeInsets.only(left: 24, right: 24), + child: Container( + decoration: BoxDecoration( + color: theme.dividerColor.withOpacity(0.25), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(widget.url!), ), - ], + ), ), + const SizedBox(height: 10), + if ((page ?? widget.initialPage) == LinkBottomSheetPage.general) ...[ + PickerItem( + label: l10n.open, + icon: Icons.language, + onSelected: () => handleLinkTap(context, thunderState, widget.text, widget.url), + ), + PickerItem( + label: l10n.copy, + icon: Icons.copy_rounded, + onSelected: () => Clipboard.setData(ClipboardData(text: widget.url ?? widget.text)), + ), + PickerItem( + label: l10n.share, + icon: Icons.share_rounded, + onSelected: () => Share.share(widget.url ?? widget.text), + ), + PickerItem( + label: l10n.alternateSources, + icon: Icons.link_rounded, + onSelected: () => setState(() => page = LinkBottomSheetPage.alternateLinks), + trailingIcon: Icons.chevron_right_rounded, + ), + ], + if ((page ?? widget.initialPage) == LinkBottomSheetPage.alternateLinks) + ...generateAlternateSources(widget.url ?? widget.text).map((alternateSource) { + return PickerItem( + label: alternateSource.sourceName, + subtitle: alternateSource.link, + icon: Icons.archive_rounded, + onSelected: () { + if (widget.customNavigation != null) { + widget.customNavigation!.call(alternateSource.link); + } else { + handleLink(context, url: alternateSource.link); + } + + Navigator.of(context).pop(); + }, + trailingIcon: Icons.chevron_right_rounded, + ); + }), + const SizedBox(height: 40.0), ], ), - items: [ - ListPickerItem(label: l10n.open, payload: 'open', icon: Icons.language), - ListPickerItem(label: l10n.copy, payload: 'copy', icon: Icons.copy_rounded), - ListPickerItem(label: l10n.share, payload: 'share', icon: Icons.share_rounded), - ], - onSelect: (value) async { - switch (value.payload) { - case 'open': - handleLinkTap(context, state, text, url); - break; - case 'copy': - Clipboard.setData(ClipboardData(text: url)); - break; - case 'share': - Share.share(url); - break; - } - }, ), - ); - }, - ); + ), + ); + } } Future handleLinkTap(BuildContext context, ThunderState state, String text, String? url) async { @@ -406,3 +505,15 @@ Future handleLinkTap(BuildContext context, ThunderState state, String text handleLink(context, url: parsedUrl); } } + +List<({String sourceName, String link})> generateAlternateSources(String link) { + return _alternateSources.map((alternateSource) { + return (sourceName: alternateSource.sourceName, link: alternateSource.template.format({'link': link})); + }).toList(); +} + +List<({String sourceName, MessageFormat template})> _alternateSources = [ + (sourceName: 'Archive Today', template: MessageFormat('https://archive.today/{link}')), + (sourceName: 'Internet Archive', template: MessageFormat('https://web.archive.org/save/{link}')), + (sourceName: 'Ground News', template: MessageFormat('https://ground.news/find?url={link}')), +];