mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 12:23:31 +00:00
Initial Link Preview Capabilities
This commit is contained in:
parent
693f781ea9
commit
b30ba7c057
10 changed files with 425 additions and 58 deletions
|
@ -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(
|
||||
|
|
40
lib/controls/timeline/link_preview_control.dart
Normal file
40
lib/controls/timeline/link_preview_control.dart
Normal file
|
@ -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))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
39
lib/models/link_preview_data.dart
Normal file
39
lib/models/link_preview_data.dart
Normal file
|
@ -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}';
|
||||
}
|
||||
}
|
|
@ -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,8 +55,10 @@ class TimelineEntry {
|
|||
|
||||
final EngagementSummary engagementSummary;
|
||||
|
||||
TimelineEntry({
|
||||
this.id = '',
|
||||
final LinkPreviewData? linkPreviewData;
|
||||
|
||||
TimelineEntry(
|
||||
{this.id = '',
|
||||
this.parentId = '',
|
||||
this.creationTimestamp = 0,
|
||||
this.backdatedTimestamp = 0,
|
||||
|
@ -79,7 +82,7 @@ class TimelineEntry {
|
|||
this.dislikes = const [],
|
||||
this.mediaAttachments = const [],
|
||||
this.engagementSummary = const EngagementSummary(),
|
||||
});
|
||||
this.linkPreviewData});
|
||||
|
||||
TimelineEntry.randomBuilt()
|
||||
: creationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
||||
|
@ -105,10 +108,11 @@ class TimelineEntry {
|
|||
likes = [],
|
||||
dislikes = [],
|
||||
mediaAttachments = [],
|
||||
engagementSummary = const EngagementSummary();
|
||||
engagementSummary = const EngagementSummary(),
|
||||
linkPreviewData = LinkPreviewData(link: 'fake link');
|
||||
|
||||
TimelineEntry copy(
|
||||
{int? creationTimestamp,
|
||||
TimelineEntry copy({
|
||||
int? creationTimestamp,
|
||||
int? backdatedTimestamp,
|
||||
int? modificationTimestamp,
|
||||
bool? isReshare,
|
||||
|
@ -131,7 +135,9 @@ class TimelineEntry {
|
|||
List<Connection>? likes,
|
||||
List<Connection>? dislikes,
|
||||
List<MediaAttachment>? mediaAttachments,
|
||||
EngagementSummary? engagementSummary}) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]";
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
72
lib/utils/opengraph_preview_grabber.dart
Normal file
72
lib/utils/opengraph_preview_grabber.dart
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
11
test/opengraph_preview_grabber_test.dart
Normal file
11
test/opengraph_preview_grabber_test.dart
Normal file
|
@ -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/'));
|
||||
}
|
Loading…
Reference in a new issue