diff --git a/lib/core/misc/custom_bb_tags.dart b/lib/core/misc/custom_bb_tags.dart index 64c513f1..bf0d6cad 100644 --- a/lib/core/misc/custom_bb_tags.dart +++ b/lib/core/misc/custom_bb_tags.dart @@ -7,7 +7,10 @@ import 'package:flutter_bbcode/flutter_bbcode.dart'; import 'package:injector/injector.dart'; import 'package:unn_mobile/core/constants/api/protocol_type.dart'; import 'package:unn_mobile/core/misc/hex_color.dart'; +import 'package:unn_mobile/core/misc/html_utils/html_widget_callbacks.dart'; import 'package:unn_mobile/core/services/interfaces/common/logger_service.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_factory.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_helper.dart'; import 'package:unn_mobile/ui/widgets/spoiler_display.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -351,6 +354,54 @@ class UserTag extends StyleTag { oldStyle; } +class UrlTag extends WrappedStyleTag { + final Future Function(String url)? onTap; + final void Function(String url, BuildContext context)? onLongPress; + + UrlTag({ + this.onTap, + this.onLongPress, + }) : super('url'); + + @override + List wrap( + FlutterRenderer renderer, + bbob.Element element, + List spans, + ) { + final String url = element.attributes.keys.firstOrNull ?? + (spans.isNotEmpty && spans[0] is TextSpan + ? (spans[0] as TextSpan).text ?? '' + : 'URL is missing!'); + + final textSpan = TextSpan( + text: spans.isEmpty ? url : null, + children: spans, + style: renderer.getCurrentStyle().copyWith( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + ); + + return [ + WidgetSpan( + child: Builder( + builder: (ctx) => GestureDetector( + onTap: () => onTap?.call(url), + onLongPress: + onLongPress != null ? () => onLongPress!(url, ctx) : null, + child: RichText( + text: textSpan, + textAlign: TextAlign.left, + softWrap: true, + ), + ), + ), + ), + ]; + } +} + BBStylesheet getBBStyleSheet() => defaultBBStylesheet() .copyWith(selectableText: true) .replaceTag( @@ -362,6 +413,17 @@ BBStylesheet getBBStyleSheet() => defaultBBStylesheet() .log('Could not launch url $url'); } }, + onLongPress: (url, ctx) { + ContextMenuHelper.showContextMenu( + context: ctx, + model: url, + actionsBuilder: () => createLinkActions( + context: ctx, + url: url, + onOpen: () => htmlWidgetOnTapUrl(url), + ), + ); + }, ), ) .addTag(PTag()) diff --git a/lib/ui/views/main_page/feed/widgets/feed_comment.dart b/lib/ui/views/main_page/feed/widgets/feed_comment.dart index 22d2102e..e6159b91 100644 --- a/lib/ui/views/main_page/feed/widgets/feed_comment.dart +++ b/lib/ui/views/main_page/feed/widgets/feed_comment.dart @@ -11,8 +11,8 @@ import 'package:unn_mobile/ui/views/base_view.dart'; import 'package:unn_mobile/ui/views/main_page/feed/functions/reactions_window.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/attached_file.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/packed_post_images.dart'; -import 'package:unn_mobile/ui/views/main_page/feed/widgets/post_html_widget.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/reaction_bubble.dart'; +import 'package:unn_mobile/ui/views/main_page/feed/widgets/text_html_widget.dart'; import 'package:unn_mobile/ui/widgets/context_menu/context_menu_factory.dart'; import 'package:unn_mobile/ui/widgets/context_menu/context_menu_helper.dart'; import 'package:unn_mobile/ui/widgets/shimmer.dart'; @@ -54,7 +54,7 @@ class FeedCommentView extends StatelessWidget { top: 8, ), child: model.renderMessage - ? PostHtmlWidget(text: model.message) + ? TextHtmlWidget(text: model.message) : const SizedBox(), ), Padding( diff --git a/lib/ui/views/main_page/feed/widgets/feed_post.dart b/lib/ui/views/main_page/feed/widgets/feed_post.dart index a3d997fe..6a858872 100644 --- a/lib/ui/views/main_page/feed/widgets/feed_post.dart +++ b/lib/ui/views/main_page/feed/widgets/feed_post.dart @@ -24,7 +24,7 @@ import 'package:unn_mobile/ui/views/base_view.dart'; import 'package:unn_mobile/ui/views/main_page/feed/functions/reactions_window.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/attached_file.dart'; import 'package:unn_mobile/ui/views/main_page/feed/widgets/packed_post_images.dart'; -import 'package:unn_mobile/ui/views/main_page/feed/widgets/post_html_widget.dart'; +import 'package:unn_mobile/ui/views/main_page/feed/widgets/text_html_widget.dart'; import 'package:unn_mobile/ui/views/main_page/main_page_routing.dart'; import 'package:unn_mobile/ui/widgets/context_menu/context_menu_factory.dart'; import 'package:unn_mobile/ui/widgets/context_menu/context_menu_helper.dart'; @@ -333,7 +333,7 @@ class _FeedPostState extends State { Widget _buildPostContent(FeedPostViewModel model) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - PostHtmlWidget(text: model.postText), + TextHtmlWidget(text: model.postText), PackedPostImages(attachedImages: model.attachedImages), ], ); diff --git a/lib/ui/views/main_page/feed/widgets/post_html_widget.dart b/lib/ui/views/main_page/feed/widgets/post_html_widget.dart deleted file mode 100644 index f0912f96..00000000 --- a/lib/ui/views/main_page/feed/widgets/post_html_widget.dart +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 BitCodersNN - -import 'package:extended_image/extended_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; -import 'package:unn_mobile/core/misc/html_utils/html_widget_callbacks.dart'; -import 'package:unn_mobile/ui/widgets/dismissable_image.dart'; - -class PostHtmlWidget extends StatelessWidget { - const PostHtmlWidget({ - required this.text, - super.key, - }); - - final String text; - - @override - Widget build(BuildContext context) => HtmlWidget( - text, - onTapUrl: htmlWidgetOnTapUrl, - onTapImage: (imageMetadata) async { - await showDialog( - context: context, - builder: (context) => ExtendedImageSlidePage( - slideAxis: SlideAxis.vertical, - child: DismissibleImage(image: imageMetadata.sources.first.url), - ), - ); - }, - ); -} diff --git a/lib/ui/views/main_page/feed/widgets/text_html_widget.dart b/lib/ui/views/main_page/feed/widgets/text_html_widget.dart new file mode 100644 index 00000000..f5de6006 --- /dev/null +++ b/lib/ui/views/main_page/feed/widgets/text_html_widget.dart @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 BitCodersNN + +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; +import 'package:unn_mobile/core/misc/html_utils/html_widget_callbacks.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_factory.dart'; +import 'package:unn_mobile/ui/widgets/context_menu/context_menu_helper.dart'; +import 'package:unn_mobile/ui/widgets/dismissable_image.dart'; + +class TextHtmlWidget extends StatelessWidget { + const TextHtmlWidget({required this.text, super.key}); + final String text; + + @override + Widget build(BuildContext context) => HtmlWidget( + text, + onTapUrl: htmlWidgetOnTapUrl, + onTapImage: (imageMetadata) async { + await showDialog( + context: context, + builder: (context) => ExtendedImageSlidePage( + slideAxis: SlideAxis.vertical, + child: DismissibleImage(image: imageMetadata.sources.first.url), + ), + ); + }, + customWidgetBuilder: (element) { + if (element.localName == 'a') { + final href = element.attributes['href']; + final text = element.text; + if (href != null) { + return Builder( + builder: (ctx) => GestureDetector( + onTap: () => htmlWidgetOnTapUrl(href), + onLongPress: () => ContextMenuHelper.showContextMenu( + context: ctx, + model: href, + actionsBuilder: () => createLinkActions( + context: ctx, + url: href, + onOpen: () => htmlWidgetOnTapUrl(href), + ), + ), + child: Text( + text, + style: TextStyle( + color: Theme.of(ctx).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ); + } + } + return null; + }, + ); +} diff --git a/lib/ui/widgets/context_menu/context_menu_factory.dart b/lib/ui/widgets/context_menu/context_menu_factory.dart index 77e3a2e4..4062999e 100644 --- a/lib/ui/widgets/context_menu/context_menu_factory.dart +++ b/lib/ui/widgets/context_menu/context_menu_factory.dart @@ -9,6 +9,7 @@ import 'package:unn_mobile/core/viewmodels/main_page/feed/feed_comment_view_mode import 'package:unn_mobile/core/viewmodels/main_page/feed/feed_post_view_model.dart'; import 'package:unn_mobile/ui/views/main_page/chat/widgets/message.dart'; import 'package:unn_mobile/ui/widgets/context_menu/context_menu_action.dart'; +import 'package:url_launcher/url_launcher.dart'; List createMessageActions({ required BuildContext context, @@ -46,14 +47,29 @@ List createCommentActions({ textToCopy: model.message, ); +List createLinkActions({ + required BuildContext context, + required String url, + VoidCallback? onOpen, + VoidCallback? onShare, +}) => + _createActions( + context: context, + linkToCopy: url, + onOpenLink: onOpen, + onShare: onShare, + ); + List _createActions({ required BuildContext context, ReactionViewModelBase? reactionViewModel, String? textToCopy, + String? linkToCopy, VoidCallback? onReply, VoidCallback? onTogglePin, bool? isPinned, VoidCallback? onShare, + VoidCallback? onOpenLink, }) { final actions = []; @@ -76,6 +92,34 @@ List _createActions({ ); } + if (linkToCopy != null) { + actions + ..add( + ContextMenuAction.text( + label: 'Открыть', + onTap: onOpenLink ?? + () { + launchUrl(Uri.parse(linkToCopy)); + }, + leadingIcon: const Icon(Icons.open_in_new, size: 18), + ), + ) + ..add( + ContextMenuAction.text( + label: 'Скопировать ссылку', + onTap: () { + Clipboard.setData(ClipboardData(text: linkToCopy)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ссылка скопирована')), + ); + } + }, + leadingIcon: const Icon(Icons.link, size: 18), + ), + ); + } + if (onReply != null) { actions.add( ContextMenuAction.text( diff --git a/pubspec.yaml b/pubspec.yaml index 17eee5d4..5aa16867 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: unn_mobile description: A mobile application for UNN Portal website publish_to: 'none' -version: 0.6.0+367 +version: 0.6.0+368 environment: sdk: '>=3.1.2 <4.0.0'