Initial Link Preview Capabilities

codemagic-setup
Hank Grabowski 2023-03-19 16:27:57 -04:00
rodzic 693f781ea9
commit b30ba7c057
10 zmienionych plików z 425 dodań i 58 usunięć

Wyświetl plik

@ -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<FlattenedTreeEntryControl> {
const VerticalPadding(
height: 5,
),
if (entry.linkPreviewData != null)
LinkPreviewControl(preview: entry.linkPreviewData!),
buildMediaBar(context),
],
const VerticalPadding(

Wyświetl plik

@ -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))),
),
],
),
),
);
}
}

Wyświetl plik

@ -0,0 +1,39 @@
class LinkPreviewData {
final String link;
final String title;
final String description;
final String siteName;
final String selectedImageUrl;
final List<String> 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<String>? 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}';
}
}

Wyświetl plik

@ -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<LinkData>? links,
List<Connection>? likes,
List<Connection>? dislikes,
List<MediaAttachment>? 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<LinkData>? links,
List<Connection>? likes,
List<Connection>? dislikes,
List<MediaAttachment>? 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,
);
}

Wyświetl plik

@ -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<EditorScreen> {
final spoilerController = TextEditingController();
final localEntryTemporaryId = const Uuid().v4();
TimelineEntry? parentEntry;
final linkPreviewController = TextEditingController();
LinkPreviewData? linkPreviewData;
final newMediaItems = NewEntryMediaItems();
final existingMediaItems = <ImageEntry>[];
final focusNode = FocusNode();
@ -90,6 +97,9 @@ class _EditorScreenState extends State<EditorScreen> {
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<EditorScreen> {
});
}
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<void> 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<EditorScreen> {
});
final result = await manager.createNewStatus(
contentController.text,
bodyText,
spoilerText: spoilerController.text,
inReplyToId: widget.parentId,
newMediaItems: newMediaItems,
@ -137,8 +168,8 @@ class _EditorScreenState extends State<EditorScreen> {
}
Future<void> 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<EditorScreen> {
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<EditorScreen> {
const VerticalPadding(),
buildContentField(context),
const VerticalPadding(),
buildLinkWithPreview(context),
const VerticalPadding(),
GallerySelectorControl(entries: existingMediaItems),
const VerticalPadding(),
MediaUploadsControl(
@ -390,4 +423,128 @@ class _EditorScreenState extends State<EditorScreen> {
],
);
}
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]);
});
}
}

Wyświetl plik

@ -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]";
}
}

Wyświetl plik

@ -0,0 +1,23 @@
import '../../models/link_preview_data.dart';
extension LinkPreviewMastodonExtensions on LinkPreviewData {
static LinkPreviewData? fromJson(Map<String, dynamic>? 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 ? <String>[] : [image];
return LinkPreviewData(
link: link,
title: title,
description: description,
siteName: siteName,
selectedImageUrl: image,
availableImageUrls: images,
);
}
}

Wyświetl plik

@ -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<ActiveProfileSelector<ConnectionsManager>>().activeEntry.fold(
@ -112,6 +115,7 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
links: linkData,
mediaAttachments: mediaAttachments,
engagementSummary: engagementSummary,
linkPreviewData: linkPreviewData,
);
}
}

Wyświetl plik

@ -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<LinkPreviewData, ExecError> 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<List<MapEntry<String, String>>, dynamic> _getOpenGraphData(
String url) async {
return runCatchingAsync<List<MapEntry<String, String>>>(() 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<MapEntry<String, String>> {
String getValue(String key) {
return firstWhere(
(e) => e.key == key,
orElse: () => const MapEntry('', ''),
).value;
}
List<String> getValues(String key) {
return where((e) => e.key == key).map((e) => e.value).toList();
}
}

Wyświetl plik

@ -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/'));
}