Skip to content

Commit

Permalink
Fixes for edge cases with markdown link parsing (#898)
Browse files Browse the repository at this point in the history
* re-implemented long-press action modal for markdown links

* updated changelog

* linting
  • Loading branch information
hjiangsu committed Nov 17, 2023
1 parent c7154c7 commit 0bc0623
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 123 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## Unreleased
### Fixed
- Fixed rendering issues with markdown link parsing

## 0.2.5+25 - 2023-11-15
### Added
Expand Down
2 changes: 1 addition & 1 deletion lib/shared/comment_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class _CommentContentState extends State<CommentContent> with SingleTickerProvid
children: [
Padding(
padding: EdgeInsets.only(top: 0, right: 8.0, left: 8.0, bottom: (state.showCommentButtonActions && widget.isUserLoggedIn) ? 0.0 : 8.0),
child: CommonMarkdownBody(body: widget.comment.comment.content),
child: CommonMarkdownBody(body: widget.comment.comment.content, isComment: true),
),
if (state.showCommentButtonActions && widget.isUserLoggedIn)
Padding(
Expand Down
215 changes: 94 additions & 121 deletions lib/shared/common_markdown_body.dart
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:html/parser.dart';
import 'package:link_preview_generator/link_preview_generator.dart';

import 'package:markdown/markdown.dart' as md;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:share_plus/share_plus.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:link_preview_generator/link_preview_generator.dart';

import 'package:thunder/account/models/account.dart';
import 'package:thunder/core/auth/helpers/fetch_account.dart';
import 'package:thunder/core/enums/font_scale.dart';
import 'package:thunder/core/singletons/lemmy_client.dart';
import 'package:thunder/feed/utils/utils.dart';
import 'package:thunder/feed/view/feed_page.dart';
import 'package:thunder/post/utils/post.dart';
import 'package:thunder/shared/image_preview.dart';
import 'package:thunder/utils/bottom_sheet_list_picker.dart';
import 'package:thunder/utils/links.dart';
import 'package:thunder/thunder/bloc/thunder_bloc.dart';
import 'package:thunder/utils/instance.dart';
import 'package:thunder/utils/navigate_comment.dart';
import 'package:thunder/utils/navigate_post.dart';
import 'package:thunder/utils/navigate_user.dart';
import 'package:thunder/utils/markdown/extended_markdown.dart';

class CommonMarkdownBody extends StatelessWidget {
/// The markdown content body
final String body;

/// Whether the text is selectable - defaults to false
final bool isSelectableText;

/// Indicates if the given markdown is a comment. Depending on the markdown content, different text scaling may be applied
/// TODO: This should be converted to an enum of possible markdown content (e.g., post, comment, general, metadata, etc.) to allow for more fined-tuned scaling of text
final bool? isComment;

const CommonMarkdownBody({
Expand All @@ -41,14 +36,14 @@ class CommonMarkdownBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;

final ThunderState state = context.watch<ThunderBloc>().state;

return MarkdownBody(
return ExtendedMarkdownBody(
// TODO We need spoiler support here
data: body,
builders: {
'a': LinkElementBuilder(context: context, state: state, isComment: isComment),
},
inlineSyntaxes: [LemmyLinkSyntax()],
imageBuilder: (uri, title, alt) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
Expand All @@ -66,9 +61,74 @@ class CommonMarkdownBody extends StatelessWidget {
);
},
selectable: isSelectableText,
// Since we're now rending links ourselves, we do not want a separate onTapLink handler.
// In fact, when this is here, it triggers on text that doesn't even represent a link.
//onTapLink: (text, url, title) => _handleLinkTap(context, state, text, url),
onTapLink: (text, url, title) => _handleLinkTap(context, state, text, url),
onLongPressLink: (text, url, title) {
HapticFeedback.mediumImpact();
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) {
bool isValidUrl = url?.startsWith('http') ?? false;

return BottomSheetListPicker(
title: l10n.linkActions,
heading: Column(
children: [
if (isValidUrl) ...[
LinkPreviewGenerator(
link: url!,
placeholderWidget: const CircularProgressIndicator(),
linkPreviewStyle: LinkPreviewStyle.large,
cacheDuration: Duration.zero,
onTap: () {},
bodyTextOverflow: TextOverflow.fade,
graphicFit: BoxFit.scaleDown,
removeElevation: true,
backgroundColor: theme.dividerColor.withOpacity(0.25),
borderRadius: 10,
),
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!),
),
),
],
),
],
),
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) {
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;
}
},
);
},
);
},
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
textScaleFactor: MediaQuery.of(context).textScaleFactor * (isComment == true ? state.commentFontSizeScale.textScaleFactor : state.contentFontSizeScale.textScaleFactor),
p: theme.textTheme.bodyMedium,
Expand Down Expand Up @@ -103,109 +163,22 @@ Future<void> _handleLinkTap(BuildContext context, ThunderState state, String tex
}
}

/// Creates a [MarkdownElementBuilder] that renders links.
class LinkElementBuilder extends MarkdownElementBuilder {
final BuildContext context;
final ThunderState state;
final bool? isComment;
class LemmyLinkSyntax extends md.InlineSyntax {
// https://github.com/LemmyNet/lemmy-ui/blob/61255bf01a8d2acdbb77229838002bf8067ada70/src/shared/config.ts#L38
static const String _pattern = r'(\/[cmu]\/|@|!)([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})';

LinkElementBuilder({required this.context, required this.state, required this.isComment});
LemmyLinkSyntax() : super(_pattern);

@override
Widget? visitElementAfterWithContext(BuildContext context, md.Element element, TextStyle? preferredStyle, TextStyle? parentStyle) {
final ThemeData theme = Theme.of(context);
final AppLocalizations l10n = AppLocalizations.of(context)!;

String? href = element.attributes['href'];
if (href == null) {
// Not a link
return super.visitElementAfterWithContext(context, element, preferredStyle, parentStyle);
} else if (href.startsWith('mailto:')) {
href = href.replaceFirst('mailto:', '');
}

return RichText(
text: TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: InkWell(
borderRadius: BorderRadius.circular(5),
onTap: () => _handleLinkTap(context, state, element.textContent, href),
onLongPress: () {
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) => BottomSheetListPicker(
title: l10n.linkActions,
heading: Column(
children: [
if (!element.attributes['href']!.startsWith('mailto:')) ...[
LinkPreviewGenerator(
link: href!,
placeholderWidget: const CircularProgressIndicator(),
linkPreviewStyle: LinkPreviewStyle.large,
cacheDuration: Duration.zero,
onTap: () {},
bodyTextOverflow: TextOverflow.fade,
graphicFit: BoxFit.scaleDown,
removeElevation: true,
backgroundColor: theme.dividerColor.withOpacity(0.25),
borderRadius: 10,
),
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(href!),
),
),
],
),
],
),
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) {
switch (value.payload) {
case 'open':
_handleLinkTap(context, state, element.textContent, href);
break;
case 'copy':
Clipboard.setData(ClipboardData(text: href!));
break;
case 'share':
Share.share(href!);
break;
}
},
),
);
},
child: Text(
element.textContent,
// Note that we don't need to specify a textScaleFactor here because it's already applied by the styleSheet of the parent
style: theme.textTheme.bodyMedium?.copyWith(
// TODO: In the future, we could consider using a theme color (or a blend) here.
color: Colors.blue,
),
),
),
),
],
),
);
bool onMatch(md.InlineParser parser, Match match) {
final modifier = match[1]!;
final name = match[2]!;
final url = match[3]!;
final anchor = md.Element.text('a', '$modifier$name@$url');

anchor.attributes['href'] = '$modifier$name@$url';
parser.addNode(anchor);

return true;
}
}
2 changes: 1 addition & 1 deletion lib/user/widgets/comment_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class CommentCard extends StatelessWidget {
onTap: () => navigateToFeedPage(context, feedType: FeedType.community, communityId: comment.community.id),
),
const SizedBox(height: 10),
CommonMarkdownBody(body: comment.comment.content),
CommonMarkdownBody(body: comment.comment.content, isComment: true),
const Divider(height: 20),
const Row(
mainAxisAlignment: MainAxisAlignment.end,
Expand Down
Loading

0 comments on commit 0bc0623

Please sign in to comment.