diff --git a/lib/controls/timeline/flattened_tree_entry_control.dart b/lib/controls/timeline/flattened_tree_entry_control.dart index e4ca263..90c2862 100644 --- a/lib/controls/timeline/flattened_tree_entry_control.dart +++ b/lib/controls/timeline/flattened_tree_entry_control.dart @@ -8,6 +8,7 @@ import '../../utils/url_opening_utils.dart'; import '../media_attachment_viewer_control.dart'; import '../padding.dart'; import 'interactions_bar_control.dart'; +import 'link_preview_control.dart'; import 'status_header_control.dart'; class FlattenedTreeEntryControl extends StatefulWidget { @@ -82,6 +83,8 @@ class _StatusControlState extends State { const VerticalPadding( height: 5, ), + if (entry.linkPreviewData != null) + LinkPreviewControl(preview: entry.linkPreviewData!), buildMediaBar(context), ], const VerticalPadding( diff --git a/lib/controls/timeline/link_preview_control.dart b/lib/controls/timeline/link_preview_control.dart new file mode 100644 index 0000000..28d7749 --- /dev/null +++ b/lib/controls/timeline/link_preview_control.dart @@ -0,0 +1,40 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../models/link_preview_data.dart'; +import '../../utils/string_utils.dart'; +import '../../utils/url_opening_utils.dart'; + +class LinkPreviewControl extends StatelessWidget { + final LinkPreviewData preview; + + const LinkPreviewControl({super.key, required this.preview}); + + @override + Widget build(BuildContext context) { + const width = 128.0; + return Container( + decoration: BoxDecoration( + border: Border.all(width: 0.5), + borderRadius: BorderRadius.circular(2.0)), + child: GestureDetector( + onTap: () async { + await openUrlStringInSystembrowser(context, preview.link, 'link'); + }, + child: Row( + children: [ + Container( + width: width, + child: CachedNetworkImage(imageUrl: preview.selectedImageUrl), + ), + Expanded( + child: ListTile( + title: Text(preview.title), + subtitle: Text(preview.description.truncate(length: 128))), + ), + ], + ), + ), + ); + } +} diff --git a/lib/models/link_preview_data.dart b/lib/models/link_preview_data.dart new file mode 100644 index 0000000..6240fc2 --- /dev/null +++ b/lib/models/link_preview_data.dart @@ -0,0 +1,39 @@ +class LinkPreviewData { + final String link; + final String title; + final String description; + final String siteName; + final String selectedImageUrl; + final List availableImageUrls; + + LinkPreviewData({ + required this.link, + this.title = '', + this.description = '', + this.siteName = '', + this.selectedImageUrl = '', + this.availableImageUrls = const [], + }); + + LinkPreviewData copy({ + String? link, + String? title, + String? description, + String? siteName, + String? selectedImageUrl, + List? availableImageUrls, + }) => + LinkPreviewData( + link: link ?? this.link, + title: title ?? this.title, + description: description ?? this.description, + siteName: siteName ?? this.siteName, + selectedImageUrl: selectedImageUrl ?? this.selectedImageUrl, + availableImageUrls: availableImageUrls ?? this.availableImageUrls, + ); + + @override + String toString() { + return 'LinkPreviewData{link: $link, title: $title, description: $description, siteName: $siteName, selectedImageUrl: $selectedImageUrl, availableImageUrls: $availableImageUrls}'; + } +} diff --git a/lib/models/timeline_entry.dart b/lib/models/timeline_entry.dart index bfde671..0d4e98d 100644 --- a/lib/models/timeline_entry.dart +++ b/lib/models/timeline_entry.dart @@ -2,6 +2,7 @@ import '../globals.dart'; import 'connection.dart'; import 'engagement_summary.dart'; import 'link_data.dart'; +import 'link_preview_data.dart'; import 'location_data.dart'; import 'media_attachment.dart'; @@ -54,32 +55,34 @@ class TimelineEntry { final EngagementSummary engagementSummary; - TimelineEntry({ - this.id = '', - this.parentId = '', - this.creationTimestamp = 0, - this.backdatedTimestamp = 0, - this.modificationTimestamp = 0, - this.youReshared = false, - this.isPublic = true, - this.body = '', - this.title = '', - this.spoilerText = '', - this.author = '', - this.authorId = '', - this.parentAuthor = '', - this.parentAuthorId = '', - this.reshareAuthor = '', - this.reshareAuthorId = '', - this.externalLink = '', - this.locationData = const LocationData(), - this.isFavorited = false, - this.links = const [], - this.likes = const [], - this.dislikes = const [], - this.mediaAttachments = const [], - this.engagementSummary = const EngagementSummary(), - }); + final LinkPreviewData? linkPreviewData; + + TimelineEntry( + {this.id = '', + this.parentId = '', + this.creationTimestamp = 0, + this.backdatedTimestamp = 0, + this.modificationTimestamp = 0, + this.youReshared = false, + this.isPublic = true, + this.body = '', + this.title = '', + this.spoilerText = '', + this.author = '', + this.authorId = '', + this.parentAuthor = '', + this.parentAuthorId = '', + this.reshareAuthor = '', + this.reshareAuthorId = '', + this.externalLink = '', + this.locationData = const LocationData(), + this.isFavorited = false, + this.links = const [], + this.likes = const [], + this.dislikes = const [], + this.mediaAttachments = const [], + this.engagementSummary = const EngagementSummary(), + this.linkPreviewData}); TimelineEntry.randomBuilt() : creationTimestamp = DateTime.now().millisecondsSinceEpoch, @@ -105,33 +108,36 @@ class TimelineEntry { likes = [], dislikes = [], mediaAttachments = [], - engagementSummary = const EngagementSummary(); + engagementSummary = const EngagementSummary(), + linkPreviewData = LinkPreviewData(link: 'fake link'); - TimelineEntry copy( - {int? creationTimestamp, - int? backdatedTimestamp, - int? modificationTimestamp, - bool? isReshare, - bool? isPublic, - String? id, - String? parentId, - String? externalLink, - String? body, - String? title, - String? spoilerText, - String? author, - String? authorId, - String? parentAuthor, - String? parentAuthorId, - String? reshareAuthor, - String? reshareAuthorId, - LocationData? locationData, - bool? isFavorited, - List? links, - List? likes, - List? dislikes, - List? mediaAttachments, - EngagementSummary? engagementSummary}) { + TimelineEntry copy({ + int? creationTimestamp, + int? backdatedTimestamp, + int? modificationTimestamp, + bool? isReshare, + bool? isPublic, + String? id, + String? parentId, + String? externalLink, + String? body, + String? title, + String? spoilerText, + String? author, + String? authorId, + String? parentAuthor, + String? parentAuthorId, + String? reshareAuthor, + String? reshareAuthorId, + LocationData? locationData, + bool? isFavorited, + List? links, + List? likes, + List? dislikes, + List? mediaAttachments, + EngagementSummary? engagementSummary, + LinkPreviewData? linkPreviewData, + }) { return TimelineEntry( creationTimestamp: creationTimestamp ?? this.creationTimestamp, backdatedTimestamp: backdatedTimestamp ?? this.backdatedTimestamp, @@ -158,6 +164,7 @@ class TimelineEntry { dislikes: dislikes ?? this.dislikes, mediaAttachments: mediaAttachments ?? this.mediaAttachments, engagementSummary: engagementSummary ?? this.engagementSummary, + linkPreviewData: linkPreviewData ?? this.linkPreviewData, ); } diff --git a/lib/screens/editor.dart b/lib/screens/editor.dart index 1296281..0aaac70 100644 --- a/lib/screens/editor.dart +++ b/lib/screens/editor.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; @@ -15,13 +16,17 @@ import '../controls/standard_appbar.dart'; import '../controls/timeline/status_header_control.dart'; import '../globals.dart'; import '../models/image_entry.dart'; +import '../models/link_preview_data.dart'; import '../models/media_attachment_uploads/new_entry_media_items.dart'; import '../models/timeline_entry.dart'; +import '../serializers/friendica/link_preview_friendica_extensions.dart'; import '../services/feature_version_checker.dart'; import '../services/timeline_manager.dart'; import '../utils/active_profile_selector.dart'; import '../utils/html_to_edit_text_helper.dart'; +import '../utils/opengraph_preview_grabber.dart'; import '../utils/snackbar_builder.dart'; +import '../utils/string_utils.dart'; class EditorScreen extends StatefulWidget { final String id; @@ -41,6 +46,8 @@ class _EditorScreenState extends State { final spoilerController = TextEditingController(); final localEntryTemporaryId = const Uuid().v4(); TimelineEntry? parentEntry; + final linkPreviewController = TextEditingController(); + LinkPreviewData? linkPreviewData; final newMediaItems = NewEntryMediaItems(); final existingMediaItems = []; final focusNode = FocusNode(); @@ -90,6 +97,9 @@ class _EditorScreenState extends State { spoilerController.text = entry.spoilerText; existingMediaItems .addAll(entry.mediaAttachments.map((e) => e.toImageEntry())); + if (entry.linkPreviewData?.link.isNotEmpty ?? false) { + restoreLinkPreviewData(entry.linkPreviewData!); + } setState(() { loaded = true; }); @@ -104,10 +114,31 @@ class _EditorScreenState extends State { }); } + void restoreLinkPreviewData(LinkPreviewData preview) { + linkPreviewController.text = preview.link; + linkPreviewData = preview; + Future.delayed(const Duration(seconds: 1), () async { + final updatedPreview = await getLinkPreview(preview.link); + if (updatedPreview.isSuccess) { + linkPreviewData = linkPreviewData?.copy( + availableImageUrls: updatedPreview.value.availableImageUrls); + setState(() {}); + } + }); + } + + String get bodyText => + '${contentController.text} ${linkPreviewData?.toBodyAttachment() ?? ''}'; + + bool get isEmptyPost => + bodyText.isEmpty && + existingMediaItems.isEmpty && + newMediaItems.attachments.isEmpty; + Future createStatus( BuildContext context, TimelineManager manager) async { - if (contentController.text.isEmpty) { - buildSnackbar(context, "Can't submit an empty post/comment"); + if (isEmptyPost) { + buildSnackbar(context, "Can't submit an empty $statusType"); return; } @@ -116,7 +147,7 @@ class _EditorScreenState extends State { }); final result = await manager.createNewStatus( - contentController.text, + bodyText, spoilerText: spoilerController.text, inReplyToId: widget.parentId, newMediaItems: newMediaItems, @@ -137,8 +168,8 @@ class _EditorScreenState extends State { } Future editStatus(BuildContext context, TimelineManager manager) async { - if (contentController.text.isEmpty) { - buildSnackbar(context, "Can't submit an empty post/comment"); + if (isEmptyPost) { + buildSnackbar(context, "Can't submit an empty $statusType"); return; } @@ -148,7 +179,7 @@ class _EditorScreenState extends State { final result = await manager.editStatus( widget.id, - contentController.text, + bodyText, spoilerText: spoilerController.text, inReplyToId: widget.parentId, newMediaItems: newMediaItems, @@ -222,6 +253,8 @@ class _EditorScreenState extends State { const VerticalPadding(), buildContentField(context), const VerticalPadding(), + buildLinkWithPreview(context), + const VerticalPadding(), GallerySelectorControl(entries: existingMediaItems), const VerticalPadding(), MediaUploadsControl( @@ -390,4 +423,128 @@ class _EditorScreenState extends State { ], ); } + + Widget buildLinkWithPreview(BuildContext context) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: linkPreviewController, + decoration: InputDecoration( + labelText: 'Link with preview (optional)', + border: OutlineInputBorder( + borderSide: const BorderSide(), + borderRadius: BorderRadius.circular(5.0), + ), + ), + ), + ), + IconButton( + onPressed: () async { + final newPreviewResult = + await getLinkPreview(linkPreviewController.text); + newPreviewResult.match( + onSuccess: (preview) => setState(() { + linkPreviewData = preview; + }), + onError: (error) { + if (mounted) { + buildSnackbar( + context, 'Error building link preview: $error'); + } + }); + }, + icon: Icon(Icons.refresh), + ), + ], + ), + const VerticalPadding(), + if (linkPreviewData != null) buildPreviewCard(linkPreviewData!), + ], + ); + } + + Widget buildPreviewCard(LinkPreviewData preview) { + return Row( + children: [ + buildPreviewImageSelector(preview), + Expanded( + child: ListTile( + title: Text(preview.title), + subtitle: Text(preview.description.truncate(length: 128)), + trailing: IconButton( + onPressed: () { + setState(() { + linkPreviewController.text = ''; + linkPreviewData = null; + }); + }, + icon: Icon(Icons.delete), + ), + ), + ), + ], + ); + } + + Widget buildPreviewImageSelector(LinkPreviewData preview) { + const width = 128.0; + const height = 128.0; + if (preview.selectedImageUrl.isEmpty && + preview.availableImageUrls.isEmpty) { + return Container( + width: width, + height: height, + color: Colors.grey, + ); + } + + final currentImage = Container( + width: width, + height: height, + child: CachedNetworkImage(imageUrl: preview.selectedImageUrl)); + + if (preview.availableImageUrls.length < 2) { + return currentImage; + } + + return Column( + children: [ + currentImage, + // TODO Add in when Friendica no longer stomps on image previews + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // children: [ + // IconButton( + // onPressed: () => updateLinkPreviewThumbnail(preview, -1), + // icon: Icon(size: iconSize, Icons.arrow_back_ios), + // ), + // IconButton( + // onPressed: () => updateLinkPreviewThumbnail(preview, 1), + // icon: Icon(size: iconSize, Icons.arrow_forward_ios), + // ), + // ], + // ) + ], + ); + } + + void updateLinkPreviewThumbnail(LinkPreviewData preview, int increment) { + var currentIndex = + preview.availableImageUrls.indexOf(preview.selectedImageUrl) + + increment; + if (currentIndex < 0) { + currentIndex = preview.availableImageUrls.length - 1; + } + + if (currentIndex > preview.availableImageUrls.length - 1) { + currentIndex = 0; + } + setState(() { + linkPreviewData = preview.copy( + selectedImageUrl: preview.availableImageUrls[currentIndex]); + }); + } } diff --git a/lib/serializers/friendica/link_preview_friendica_extensions.dart b/lib/serializers/friendica/link_preview_friendica_extensions.dart new file mode 100644 index 0000000..1cd2eb9 --- /dev/null +++ b/lib/serializers/friendica/link_preview_friendica_extensions.dart @@ -0,0 +1,11 @@ +import '../../models/link_preview_data.dart'; + +extension LinkPreviewExtension on LinkPreviewData { + String toBodyAttachment() { + if (selectedImageUrl.isEmpty) { + return "[attachment type='link' url='$link' title='$title']$description[/attachment]"; + } + + return "[attachment type='link' url='$link' title='$title' image='$selectedImageUrl']$description[/attachment]"; + } +} diff --git a/lib/serializers/mastodon/link_preview_mastodon_extensions.dart b/lib/serializers/mastodon/link_preview_mastodon_extensions.dart new file mode 100644 index 0000000..c7536a5 --- /dev/null +++ b/lib/serializers/mastodon/link_preview_mastodon_extensions.dart @@ -0,0 +1,23 @@ +import '../../models/link_preview_data.dart'; + +extension LinkPreviewMastodonExtensions on LinkPreviewData { + static LinkPreviewData? fromJson(Map? json) { + if (json == null) { + return null; + } + final link = json['url']; + final title = json['title']; + final description = json['description']; + final image = json['image']?.toString() ?? ''; + final siteName = json['provider_name']; + final images = image.isEmpty ? [] : [image]; + return LinkPreviewData( + link: link, + title: title, + description: description, + siteName: siteName, + selectedImageUrl: image, + availableImageUrls: images, + ); + } +} diff --git a/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart b/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart index e90f899..904be33 100644 --- a/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart +++ b/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart @@ -12,6 +12,7 @@ import '../../utils/active_profile_selector.dart'; import '../../utils/dateutils.dart'; import 'connection_mastodon_extensions.dart'; import 'hashtag_mastodon_extensions.dart'; +import 'link_preview_mastodon_extensions.dart'; final _logger = Logger('TimelineEntryMastodonExtensions'); @@ -56,6 +57,8 @@ extension TimelineEntryMastodonExtensions on TimelineEntry { rebloggedCount: rebloggedCount, repliesCount: repliesCount, ); + final linkPreviewData = + LinkPreviewMastodonExtensions.fromJson(json['card']); final connectionManager = getIt>().activeEntry.fold( @@ -112,6 +115,7 @@ extension TimelineEntryMastodonExtensions on TimelineEntry { links: linkData, mediaAttachments: mediaAttachments, engagementSummary: engagementSummary, + linkPreviewData: linkPreviewData, ); } } diff --git a/lib/utils/opengraph_preview_grabber.dart b/lib/utils/opengraph_preview_grabber.dart new file mode 100644 index 0000000..5eb7c57 --- /dev/null +++ b/lib/utils/opengraph_preview_grabber.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:html/parser.dart'; +import 'package:http/http.dart' as http; +import 'package:result_monad/result_monad.dart'; + +import '../models/exec_error.dart'; +import '../models/link_preview_data.dart'; + +const ogTitleKey = 'og:title'; +const ogDescriptionKey = 'og:description'; +const ogSiteNameKey = 'og:site_name'; +const ogImageKey = 'og:image'; + +FutureResult getLinkPreview(String url) async { + final result = await _getOpenGraphData(url); + return result.andThenSuccess((ogData) { + final title = ogData.getValue(ogTitleKey); + final description = ogData.getValue(ogDescriptionKey); + final siteName = ogData.getValue(ogSiteNameKey); + final availableImageUrls = ogData.getValues(ogImageKey); + final selectedImageUrl = + availableImageUrls.isEmpty ? '' : availableImageUrls.first; + return LinkPreviewData( + link: url, + title: title, + description: description, + siteName: siteName, + availableImageUrls: availableImageUrls, + selectedImageUrl: selectedImageUrl, + ); + }).execErrorCast(); +} + +FutureResult>, dynamic> _getOpenGraphData( + String url) async { + return runCatchingAsync>>(() async { + final response = await http.get(Uri.parse(url)); + if (response.statusCode != 200) { + return buildErrorResult( + type: ErrorType.serverError, + message: 'Error getting link preview: ${response.statusCode}', + ); + } + + final rawHtml = utf8.decode(response.bodyBytes); + final htmlDoc = parse(rawHtml); + final openGraphTags = htmlDoc.head + ?.querySelectorAll("[property*='og:']") + .map((p) { + final key = p.attributes['property'] ?? ''; + final value = p.attributes['content'] ?? ''; + return MapEntry(key, value); + }) + .where((e) => e.key.isNotEmpty) + .toList(); + return Result.ok(openGraphTags ?? []); + }); +} + +extension OpenGraphFinders on List> { + String getValue(String key) { + return firstWhere( + (e) => e.key == key, + orElse: () => const MapEntry('', ''), + ).value; + } + + List getValues(String key) { + return where((e) => e.key == key).map((e) => e.value).toList(); + } +} diff --git a/test/opengraph_preview_grabber_test.dart b/test/opengraph_preview_grabber_test.dart new file mode 100644 index 0000000..db25dd4 --- /dev/null +++ b/test/opengraph_preview_grabber_test.dart @@ -0,0 +1,11 @@ +import 'package:relatica/utils/opengraph_preview_grabber.dart'; + +void main() async { + print(await getLinkPreview('https://youtu.be/9JG9I6Vtkg0')); + print(await getLinkPreview( + 'https://nequalsonelifestyle.com/2023/03/18/kotlin-things-i-miss-in-dart-pt1/')); + print(await getLinkPreview( + 'https://sdtimes.com/software-development/eclipse-foundation-finds-significant-momentum-for-open-source-java-this-year/')); + print(await getLinkPreview( + 'https://nequalsonelifestyle.com/2022/07/28/installing-nscde/')); +}