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 '../media_attachment_viewer_control.dart';
|
||||||
import '../padding.dart';
|
import '../padding.dart';
|
||||||
import 'interactions_bar_control.dart';
|
import 'interactions_bar_control.dart';
|
||||||
|
import 'link_preview_control.dart';
|
||||||
import 'status_header_control.dart';
|
import 'status_header_control.dart';
|
||||||
|
|
||||||
class FlattenedTreeEntryControl extends StatefulWidget {
|
class FlattenedTreeEntryControl extends StatefulWidget {
|
||||||
|
@ -82,6 +83,8 @@ class _StatusControlState extends State<FlattenedTreeEntryControl> {
|
||||||
const VerticalPadding(
|
const VerticalPadding(
|
||||||
height: 5,
|
height: 5,
|
||||||
),
|
),
|
||||||
|
if (entry.linkPreviewData != null)
|
||||||
|
LinkPreviewControl(preview: entry.linkPreviewData!),
|
||||||
buildMediaBar(context),
|
buildMediaBar(context),
|
||||||
],
|
],
|
||||||
const VerticalPadding(
|
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 'connection.dart';
|
||||||
import 'engagement_summary.dart';
|
import 'engagement_summary.dart';
|
||||||
import 'link_data.dart';
|
import 'link_data.dart';
|
||||||
|
import 'link_preview_data.dart';
|
||||||
import 'location_data.dart';
|
import 'location_data.dart';
|
||||||
import 'media_attachment.dart';
|
import 'media_attachment.dart';
|
||||||
|
|
||||||
|
@ -54,32 +55,34 @@ class TimelineEntry {
|
||||||
|
|
||||||
final EngagementSummary engagementSummary;
|
final EngagementSummary engagementSummary;
|
||||||
|
|
||||||
TimelineEntry({
|
final LinkPreviewData? linkPreviewData;
|
||||||
this.id = '',
|
|
||||||
this.parentId = '',
|
TimelineEntry(
|
||||||
this.creationTimestamp = 0,
|
{this.id = '',
|
||||||
this.backdatedTimestamp = 0,
|
this.parentId = '',
|
||||||
this.modificationTimestamp = 0,
|
this.creationTimestamp = 0,
|
||||||
this.youReshared = false,
|
this.backdatedTimestamp = 0,
|
||||||
this.isPublic = true,
|
this.modificationTimestamp = 0,
|
||||||
this.body = '',
|
this.youReshared = false,
|
||||||
this.title = '',
|
this.isPublic = true,
|
||||||
this.spoilerText = '',
|
this.body = '',
|
||||||
this.author = '',
|
this.title = '',
|
||||||
this.authorId = '',
|
this.spoilerText = '',
|
||||||
this.parentAuthor = '',
|
this.author = '',
|
||||||
this.parentAuthorId = '',
|
this.authorId = '',
|
||||||
this.reshareAuthor = '',
|
this.parentAuthor = '',
|
||||||
this.reshareAuthorId = '',
|
this.parentAuthorId = '',
|
||||||
this.externalLink = '',
|
this.reshareAuthor = '',
|
||||||
this.locationData = const LocationData(),
|
this.reshareAuthorId = '',
|
||||||
this.isFavorited = false,
|
this.externalLink = '',
|
||||||
this.links = const [],
|
this.locationData = const LocationData(),
|
||||||
this.likes = const [],
|
this.isFavorited = false,
|
||||||
this.dislikes = const [],
|
this.links = const [],
|
||||||
this.mediaAttachments = const [],
|
this.likes = const [],
|
||||||
this.engagementSummary = const EngagementSummary(),
|
this.dislikes = const [],
|
||||||
});
|
this.mediaAttachments = const [],
|
||||||
|
this.engagementSummary = const EngagementSummary(),
|
||||||
|
this.linkPreviewData});
|
||||||
|
|
||||||
TimelineEntry.randomBuilt()
|
TimelineEntry.randomBuilt()
|
||||||
: creationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
: creationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
||||||
|
@ -105,33 +108,36 @@ class TimelineEntry {
|
||||||
likes = [],
|
likes = [],
|
||||||
dislikes = [],
|
dislikes = [],
|
||||||
mediaAttachments = [],
|
mediaAttachments = [],
|
||||||
engagementSummary = const EngagementSummary();
|
engagementSummary = const EngagementSummary(),
|
||||||
|
linkPreviewData = LinkPreviewData(link: 'fake link');
|
||||||
|
|
||||||
TimelineEntry copy(
|
TimelineEntry copy({
|
||||||
{int? creationTimestamp,
|
int? creationTimestamp,
|
||||||
int? backdatedTimestamp,
|
int? backdatedTimestamp,
|
||||||
int? modificationTimestamp,
|
int? modificationTimestamp,
|
||||||
bool? isReshare,
|
bool? isReshare,
|
||||||
bool? isPublic,
|
bool? isPublic,
|
||||||
String? id,
|
String? id,
|
||||||
String? parentId,
|
String? parentId,
|
||||||
String? externalLink,
|
String? externalLink,
|
||||||
String? body,
|
String? body,
|
||||||
String? title,
|
String? title,
|
||||||
String? spoilerText,
|
String? spoilerText,
|
||||||
String? author,
|
String? author,
|
||||||
String? authorId,
|
String? authorId,
|
||||||
String? parentAuthor,
|
String? parentAuthor,
|
||||||
String? parentAuthorId,
|
String? parentAuthorId,
|
||||||
String? reshareAuthor,
|
String? reshareAuthor,
|
||||||
String? reshareAuthorId,
|
String? reshareAuthorId,
|
||||||
LocationData? locationData,
|
LocationData? locationData,
|
||||||
bool? isFavorited,
|
bool? isFavorited,
|
||||||
List<LinkData>? links,
|
List<LinkData>? links,
|
||||||
List<Connection>? likes,
|
List<Connection>? likes,
|
||||||
List<Connection>? dislikes,
|
List<Connection>? dislikes,
|
||||||
List<MediaAttachment>? mediaAttachments,
|
List<MediaAttachment>? mediaAttachments,
|
||||||
EngagementSummary? engagementSummary}) {
|
EngagementSummary? engagementSummary,
|
||||||
|
LinkPreviewData? linkPreviewData,
|
||||||
|
}) {
|
||||||
return TimelineEntry(
|
return TimelineEntry(
|
||||||
creationTimestamp: creationTimestamp ?? this.creationTimestamp,
|
creationTimestamp: creationTimestamp ?? this.creationTimestamp,
|
||||||
backdatedTimestamp: backdatedTimestamp ?? this.backdatedTimestamp,
|
backdatedTimestamp: backdatedTimestamp ?? this.backdatedTimestamp,
|
||||||
|
@ -158,6 +164,7 @@ class TimelineEntry {
|
||||||
dislikes: dislikes ?? this.dislikes,
|
dislikes: dislikes ?? this.dislikes,
|
||||||
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
|
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
|
||||||
engagementSummary: engagementSummary ?? this.engagementSummary,
|
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/material.dart';
|
||||||
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||||
import 'package:go_router/go_router.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 '../controls/timeline/status_header_control.dart';
|
||||||
import '../globals.dart';
|
import '../globals.dart';
|
||||||
import '../models/image_entry.dart';
|
import '../models/image_entry.dart';
|
||||||
|
import '../models/link_preview_data.dart';
|
||||||
import '../models/media_attachment_uploads/new_entry_media_items.dart';
|
import '../models/media_attachment_uploads/new_entry_media_items.dart';
|
||||||
import '../models/timeline_entry.dart';
|
import '../models/timeline_entry.dart';
|
||||||
|
import '../serializers/friendica/link_preview_friendica_extensions.dart';
|
||||||
import '../services/feature_version_checker.dart';
|
import '../services/feature_version_checker.dart';
|
||||||
import '../services/timeline_manager.dart';
|
import '../services/timeline_manager.dart';
|
||||||
import '../utils/active_profile_selector.dart';
|
import '../utils/active_profile_selector.dart';
|
||||||
import '../utils/html_to_edit_text_helper.dart';
|
import '../utils/html_to_edit_text_helper.dart';
|
||||||
|
import '../utils/opengraph_preview_grabber.dart';
|
||||||
import '../utils/snackbar_builder.dart';
|
import '../utils/snackbar_builder.dart';
|
||||||
|
import '../utils/string_utils.dart';
|
||||||
|
|
||||||
class EditorScreen extends StatefulWidget {
|
class EditorScreen extends StatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
|
@ -41,6 +46,8 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
final spoilerController = TextEditingController();
|
final spoilerController = TextEditingController();
|
||||||
final localEntryTemporaryId = const Uuid().v4();
|
final localEntryTemporaryId = const Uuid().v4();
|
||||||
TimelineEntry? parentEntry;
|
TimelineEntry? parentEntry;
|
||||||
|
final linkPreviewController = TextEditingController();
|
||||||
|
LinkPreviewData? linkPreviewData;
|
||||||
final newMediaItems = NewEntryMediaItems();
|
final newMediaItems = NewEntryMediaItems();
|
||||||
final existingMediaItems = <ImageEntry>[];
|
final existingMediaItems = <ImageEntry>[];
|
||||||
final focusNode = FocusNode();
|
final focusNode = FocusNode();
|
||||||
|
@ -90,6 +97,9 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
spoilerController.text = entry.spoilerText;
|
spoilerController.text = entry.spoilerText;
|
||||||
existingMediaItems
|
existingMediaItems
|
||||||
.addAll(entry.mediaAttachments.map((e) => e.toImageEntry()));
|
.addAll(entry.mediaAttachments.map((e) => e.toImageEntry()));
|
||||||
|
if (entry.linkPreviewData?.link.isNotEmpty ?? false) {
|
||||||
|
restoreLinkPreviewData(entry.linkPreviewData!);
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
loaded = true;
|
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(
|
Future<void> createStatus(
|
||||||
BuildContext context, TimelineManager manager) async {
|
BuildContext context, TimelineManager manager) async {
|
||||||
if (contentController.text.isEmpty) {
|
if (isEmptyPost) {
|
||||||
buildSnackbar(context, "Can't submit an empty post/comment");
|
buildSnackbar(context, "Can't submit an empty $statusType");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,7 +147,7 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await manager.createNewStatus(
|
final result = await manager.createNewStatus(
|
||||||
contentController.text,
|
bodyText,
|
||||||
spoilerText: spoilerController.text,
|
spoilerText: spoilerController.text,
|
||||||
inReplyToId: widget.parentId,
|
inReplyToId: widget.parentId,
|
||||||
newMediaItems: newMediaItems,
|
newMediaItems: newMediaItems,
|
||||||
|
@ -137,8 +168,8 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> editStatus(BuildContext context, TimelineManager manager) async {
|
Future<void> editStatus(BuildContext context, TimelineManager manager) async {
|
||||||
if (contentController.text.isEmpty) {
|
if (isEmptyPost) {
|
||||||
buildSnackbar(context, "Can't submit an empty post/comment");
|
buildSnackbar(context, "Can't submit an empty $statusType");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,7 +179,7 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
|
|
||||||
final result = await manager.editStatus(
|
final result = await manager.editStatus(
|
||||||
widget.id,
|
widget.id,
|
||||||
contentController.text,
|
bodyText,
|
||||||
spoilerText: spoilerController.text,
|
spoilerText: spoilerController.text,
|
||||||
inReplyToId: widget.parentId,
|
inReplyToId: widget.parentId,
|
||||||
newMediaItems: newMediaItems,
|
newMediaItems: newMediaItems,
|
||||||
|
@ -222,6 +253,8 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
const VerticalPadding(),
|
const VerticalPadding(),
|
||||||
buildContentField(context),
|
buildContentField(context),
|
||||||
const VerticalPadding(),
|
const VerticalPadding(),
|
||||||
|
buildLinkWithPreview(context),
|
||||||
|
const VerticalPadding(),
|
||||||
GallerySelectorControl(entries: existingMediaItems),
|
GallerySelectorControl(entries: existingMediaItems),
|
||||||
const VerticalPadding(),
|
const VerticalPadding(),
|
||||||
MediaUploadsControl(
|
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 '../../utils/dateutils.dart';
|
||||||
import 'connection_mastodon_extensions.dart';
|
import 'connection_mastodon_extensions.dart';
|
||||||
import 'hashtag_mastodon_extensions.dart';
|
import 'hashtag_mastodon_extensions.dart';
|
||||||
|
import 'link_preview_mastodon_extensions.dart';
|
||||||
|
|
||||||
final _logger = Logger('TimelineEntryMastodonExtensions');
|
final _logger = Logger('TimelineEntryMastodonExtensions');
|
||||||
|
|
||||||
|
@ -56,6 +57,8 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
|
||||||
rebloggedCount: rebloggedCount,
|
rebloggedCount: rebloggedCount,
|
||||||
repliesCount: repliesCount,
|
repliesCount: repliesCount,
|
||||||
);
|
);
|
||||||
|
final linkPreviewData =
|
||||||
|
LinkPreviewMastodonExtensions.fromJson(json['card']);
|
||||||
|
|
||||||
final connectionManager =
|
final connectionManager =
|
||||||
getIt<ActiveProfileSelector<ConnectionsManager>>().activeEntry.fold(
|
getIt<ActiveProfileSelector<ConnectionsManager>>().activeEntry.fold(
|
||||||
|
@ -112,6 +115,7 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
|
||||||
links: linkData,
|
links: linkData,
|
||||||
mediaAttachments: mediaAttachments,
|
mediaAttachments: mediaAttachments,
|
||||||
engagementSummary: engagementSummary,
|
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