From 1a8abe5849150516453e3b33f99e5d9c03bd727e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 25 Jun 2025 22:33:12 +0800 Subject: [PATCH] :sparkles: Better link previewing --- lib/models/embed.dart | 19 ++ lib/models/embed.freezed.dart | 160 ++++++++++++++ lib/models/embed.g.dart | 31 +++ lib/pods/link_preview.dart | 28 +++ lib/pods/link_preview.g.dart | 164 +++++++++++++++ lib/services/sharing_intent.dart | 2 +- lib/widgets/share/share_sheet.dart | 322 ++++++++++++++--------------- 7 files changed, 559 insertions(+), 167 deletions(-) create mode 100644 lib/pods/link_preview.dart create mode 100644 lib/pods/link_preview.g.dart diff --git a/lib/models/embed.dart b/lib/models/embed.dart index 51c3906..b0d22b0 100644 --- a/lib/models/embed.dart +++ b/lib/models/embed.dart @@ -21,3 +21,22 @@ sealed class SnEmbedLink with _$SnEmbedLink { factory SnEmbedLink.fromJson(Map json) => _$SnEmbedLinkFromJson(json); } + +@freezed +sealed class SnScrappedLink with _$SnScrappedLink { + const factory SnScrappedLink({ + required String type, + required String url, + required String title, + required String? description, + required String? imageUrl, + required String faviconUrl, + required String siteName, + required String? contentType, + required String? author, + required DateTime? publishedDate, + }) = _SnScrappedLink; + + factory SnScrappedLink.fromJson(Map json) => + _$SnScrappedLinkFromJson(json); +} diff --git a/lib/models/embed.freezed.dart b/lib/models/embed.freezed.dart index 09da60d..42a0367 100644 --- a/lib/models/embed.freezed.dart +++ b/lib/models/embed.freezed.dart @@ -170,6 +170,166 @@ as DateTime?, } +} + + +/// @nodoc +mixin _$SnScrappedLink { + + String get type; String get url; String get title; String? get description; String? get imageUrl; String get faviconUrl; String get siteName; String? get contentType; String? get author; DateTime? get publishedDate; +/// Create a copy of SnScrappedLink +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnScrappedLinkCopyWith get copyWith => _$SnScrappedLinkCopyWithImpl(this as SnScrappedLink, _$identity); + + /// Serializes this SnScrappedLink to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnScrappedLink&&(identical(other.type, type) || other.type == type)&&(identical(other.url, url) || other.url == url)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.faviconUrl, faviconUrl) || other.faviconUrl == faviconUrl)&&(identical(other.siteName, siteName) || other.siteName == siteName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.author, author) || other.author == author)&&(identical(other.publishedDate, publishedDate) || other.publishedDate == publishedDate)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,url,title,description,imageUrl,faviconUrl,siteName,contentType,author,publishedDate); + +@override +String toString() { + return 'SnScrappedLink(type: $type, url: $url, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl, siteName: $siteName, contentType: $contentType, author: $author, publishedDate: $publishedDate)'; +} + + +} + +/// @nodoc +abstract mixin class $SnScrappedLinkCopyWith<$Res> { + factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl; +@useResult +$Res call({ + String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate +}); + + + + +} +/// @nodoc +class _$SnScrappedLinkCopyWithImpl<$Res> + implements $SnScrappedLinkCopyWith<$Res> { + _$SnScrappedLinkCopyWithImpl(this._self, this._then); + + final SnScrappedLink _self; + final $Res Function(SnScrappedLink) _then; + +/// Create a copy of SnScrappedLink +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { + return _then(_self.copyWith( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable +as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable +as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable +as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnScrappedLink implements SnScrappedLink { + const _SnScrappedLink({required this.type, required this.url, required this.title, required this.description, required this.imageUrl, required this.faviconUrl, required this.siteName, required this.contentType, required this.author, required this.publishedDate}); + factory _SnScrappedLink.fromJson(Map json) => _$SnScrappedLinkFromJson(json); + +@override final String type; +@override final String url; +@override final String title; +@override final String? description; +@override final String? imageUrl; +@override final String faviconUrl; +@override final String siteName; +@override final String? contentType; +@override final String? author; +@override final DateTime? publishedDate; + +/// Create a copy of SnScrappedLink +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnScrappedLinkCopyWith<_SnScrappedLink> get copyWith => __$SnScrappedLinkCopyWithImpl<_SnScrappedLink>(this, _$identity); + +@override +Map toJson() { + return _$SnScrappedLinkToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnScrappedLink&&(identical(other.type, type) || other.type == type)&&(identical(other.url, url) || other.url == url)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.faviconUrl, faviconUrl) || other.faviconUrl == faviconUrl)&&(identical(other.siteName, siteName) || other.siteName == siteName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.author, author) || other.author == author)&&(identical(other.publishedDate, publishedDate) || other.publishedDate == publishedDate)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,url,title,description,imageUrl,faviconUrl,siteName,contentType,author,publishedDate); + +@override +String toString() { + return 'SnScrappedLink(type: $type, url: $url, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl, siteName: $siteName, contentType: $contentType, author: $author, publishedDate: $publishedDate)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCopyWith<$Res> { + factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl; +@override @useResult +$Res call({ + String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate +}); + + + + +} +/// @nodoc +class __$SnScrappedLinkCopyWithImpl<$Res> + implements _$SnScrappedLinkCopyWith<$Res> { + __$SnScrappedLinkCopyWithImpl(this._self, this._then); + + final _SnScrappedLink _self; + final $Res Function(_SnScrappedLink) _then; + +/// Create a copy of SnScrappedLink +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) { + return _then(_SnScrappedLink( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable +as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable +as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable +as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + } // dart format on diff --git a/lib/models/embed.g.dart b/lib/models/embed.g.dart index 57a5561..4e27a49 100644 --- a/lib/models/embed.g.dart +++ b/lib/models/embed.g.dart @@ -35,3 +35,34 @@ Map _$SnEmbedLinkToJson(_SnEmbedLink instance) => 'Author': instance.author, 'PublishedDate': instance.publishedDate?.toIso8601String(), }; + +_SnScrappedLink _$SnScrappedLinkFromJson(Map json) => + _SnScrappedLink( + type: json['type'] as String, + url: json['url'] as String, + title: json['title'] as String, + description: json['description'] as String?, + imageUrl: json['image_url'] as String?, + faviconUrl: json['favicon_url'] as String, + siteName: json['site_name'] as String, + contentType: json['content_type'] as String?, + author: json['author'] as String?, + publishedDate: + json['published_date'] == null + ? null + : DateTime.parse(json['published_date'] as String), + ); + +Map _$SnScrappedLinkToJson(_SnScrappedLink instance) => + { + 'type': instance.type, + 'url': instance.url, + 'title': instance.title, + 'description': instance.description, + 'image_url': instance.imageUrl, + 'favicon_url': instance.faviconUrl, + 'site_name': instance.siteName, + 'content_type': instance.contentType, + 'author': instance.author, + 'published_date': instance.publishedDate?.toIso8601String(), + }; diff --git a/lib/pods/link_preview.dart b/lib/pods/link_preview.dart new file mode 100644 index 0000000..02f1627 --- /dev/null +++ b/lib/pods/link_preview.dart @@ -0,0 +1,28 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:island/models/embed.dart'; +import 'package:island/pods/network.dart'; + +part 'link_preview.g.dart'; + +@riverpod +class LinkPreview extends _$LinkPreview { + @override + Future build(String url) async { + final client = ref.read(apiClientProvider); + + try { + final response = await client.get( + '/scrap/link', + queryParameters: {'url': url}, + ); + + if (response.statusCode == 200 && response.data != null) { + return SnScrappedLink.fromJson(response.data); + } + return null; + } catch (e) { + // Return null on error to show fallback UI + return null; + } + } +} diff --git a/lib/pods/link_preview.g.dart b/lib/pods/link_preview.g.dart new file mode 100644 index 0000000..fcee633 --- /dev/null +++ b/lib/pods/link_preview.g.dart @@ -0,0 +1,164 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'link_preview.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$linkPreviewHash() => r'5130593d3066155cb958d20714ee577df1f940d7'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$LinkPreview + extends BuildlessAutoDisposeAsyncNotifier { + late final String url; + + FutureOr build(String url); +} + +/// See also [LinkPreview]. +@ProviderFor(LinkPreview) +const linkPreviewProvider = LinkPreviewFamily(); + +/// See also [LinkPreview]. +class LinkPreviewFamily extends Family> { + /// See also [LinkPreview]. + const LinkPreviewFamily(); + + /// See also [LinkPreview]. + LinkPreviewProvider call(String url) { + return LinkPreviewProvider(url); + } + + @override + LinkPreviewProvider getProviderOverride( + covariant LinkPreviewProvider provider, + ) { + return call(provider.url); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'linkPreviewProvider'; +} + +/// See also [LinkPreview]. +class LinkPreviewProvider + extends AutoDisposeAsyncNotifierProviderImpl { + /// See also [LinkPreview]. + LinkPreviewProvider(String url) + : this._internal( + () => LinkPreview()..url = url, + from: linkPreviewProvider, + name: r'linkPreviewProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$linkPreviewHash, + dependencies: LinkPreviewFamily._dependencies, + allTransitiveDependencies: LinkPreviewFamily._allTransitiveDependencies, + url: url, + ); + + LinkPreviewProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.url, + }) : super.internal(); + + final String url; + + @override + FutureOr runNotifierBuild(covariant LinkPreview notifier) { + return notifier.build(url); + } + + @override + Override overrideWith(LinkPreview Function() create) { + return ProviderOverride( + origin: this, + override: LinkPreviewProvider._internal( + () => create()..url = url, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + url: url, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement + createElement() { + return _LinkPreviewProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LinkPreviewProvider && other.url == url; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, url.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin LinkPreviewRef on AutoDisposeAsyncNotifierProviderRef { + /// The parameter `url` of this provider. + String get url; +} + +class _LinkPreviewProviderElement + extends + AutoDisposeAsyncNotifierProviderElement + with LinkPreviewRef { + _LinkPreviewProviderElement(super.provider); + + @override + String get url => (origin as LinkPreviewProvider).url; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/services/sharing_intent.dart b/lib/services/sharing_intent.dart index 6f1dee2..45d87de 100644 --- a/lib/services/sharing_intent.dart +++ b/lib/services/sharing_intent.dart @@ -87,7 +87,7 @@ class SharingIntentService { // Extract links from shared content final List links = sharedFiles - .where((file) => file.type == SharedMediaType.text) + .where((file) => file.type == SharedMediaType.url) .map((file) => file.path) .toList(); diff --git a/lib/widgets/share/share_sheet.dart b/lib/widgets/share/share_sheet.dart index b215405..56ca0cc 100644 --- a/lib/widgets/share/share_sheet.dart +++ b/lib/widgets/share/share_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -9,8 +9,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:island/route.gr.dart'; import 'package:island/screens/posts/compose.dart'; import 'package:island/models/file.dart'; -import 'package:island/models/embed.dart'; -import 'package:island/pods/network.dart'; +import 'package:island/pods/link_preview.dart'; import 'dart:io'; import 'package:path/path.dart' as path; @@ -19,6 +18,7 @@ import 'package:island/screens/chat/chat.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:styled_widget/styled_widget.dart'; enum ShareContentType { text, link, file } @@ -135,7 +135,7 @@ class _ShareSheetState extends ConsumerState { // Convert ShareContent to PostComposeInitialState String content = ''; List attachments = []; - + switch (widget.content.type) { case ShareContentType.text: content = widget.content.text ?? ''; @@ -149,7 +149,7 @@ class _ShareSheetState extends ConsumerState { for (final xFile in widget.content.files!) { final file = File(xFile.path); final mimeType = xFile.mimeType; - + UniversalFileType fileType; if (mimeType?.startsWith('image/') == true) { fileType = UniversalFileType.image; @@ -160,22 +160,19 @@ class _ShareSheetState extends ConsumerState { } else { fileType = UniversalFileType.file; } - - attachments.add(UniversalFile( - data: file, - type: fileType, - )); + + attachments.add(UniversalFile(data: file, type: fileType)); } } break; } - + final initialState = PostComposeInitialState( title: widget.title, content: content, attachments: attachments, ); - + // Navigate to compose screen if (mounted) { context.router.push(PostComposeRoute(initialState: initialState)); @@ -717,185 +714,178 @@ class _TextPreview extends StatelessWidget { } } -class _LinkPreview extends HookConsumerWidget { +class _LinkPreview extends ConsumerWidget { final String link; const _LinkPreview({required this.link}); @override Widget build(BuildContext context, WidgetRef ref) { - final linkData = useState(null); - final isLoading = useState(false); - final hasError = useState(false); + final linkPreviewAsync = ref.watch(linkPreviewProvider(link)); - useEffect(() { - Future fetchLinkData() async { - if (link.isEmpty) return; - - isLoading.value = true; - hasError.value = false; - - try { - final client = ref.read(apiClientProvider); - final response = await client.get('/scrap/link', queryParameters: { - 'url': link, - }); - - if (response.data != null) { - linkData.value = SnEmbedLink.fromJson(response.data); - } - } catch (e) { - hasError.value = true; - } finally { - isLoading.value = false; - } - } - - fetchLinkData(); - return null; - }, [link]); - - if (isLoading.value) { - return Container( - constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight), - child: Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - Text( - 'Loading link preview...', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - if (hasError.value || linkData.value == null) { - return Container( - constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return linkPreviewAsync.when( + loading: + () => Container( + constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight), + child: Row( children: [ - Icon( - Symbols.link, - size: 16, - color: Theme.of(context).colorScheme.primary, + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), Text( - 'Link', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, + 'Loading link preview...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), - const SizedBox(height: 8), - Expanded( - child: SingleChildScrollView( - child: SelectableText( - link, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - decoration: TextDecoration.underline, - ), - ), - ), - ), - ], - ), - ); - } + ), + error: (error, stackTrace) => _buildFallbackPreview(context), + data: (embed) { + if (embed == null) { + return _buildFallbackPreview(context); + } - final embed = linkData.value!; - return Container( - constraints: const BoxConstraints(maxHeight: 120), // Increased height for rich preview - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Favicon and image - if (embed.imageUrl != null || embed.faviconUrl.isNotEmpty) - Container( - width: 60, - height: 60, - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context).colorScheme.surfaceContainerHighest, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: embed.imageUrl != null - ? Image.network( - embed.imageUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return _buildFaviconFallback(context, embed.faviconUrl); - }, - ) - : _buildFaviconFallback(context, embed.faviconUrl), - ), - ), - // Content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Site name - if (embed.siteName.isNotEmpty) - Text( - embed.siteName, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + return Container( + constraints: const BoxConstraints( + maxHeight: 120, + ), // Increased height for rich preview + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Favicon and image + if (embed.imageUrl != null || embed.faviconUrl.isNotEmpty) + Container( + width: 60, + height: 60, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: + Theme.of(context).colorScheme.surfaceContainerHighest, ), - // Title - Text( - embed.title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + embed.imageUrl != null + ? Image.network( + embed.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildFaviconFallback( + context, + embed.faviconUrl, + ); + }, + ) + : _buildFaviconFallback(context, embed.faviconUrl), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - // Description - if (embed.description != null && embed.description!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - embed.description!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Site name + if (embed.siteName.isNotEmpty) + Text( + embed.siteName, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Title + Text( + embed.title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), - ), - // URL - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - embed.url, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - decoration: TextDecoration.underline, + // Description + if (embed.description != null && + embed.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + embed.description!, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + // URL + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + embed.url, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + decoration: TextDecoration.underline, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + ], ), - ], + ), + ], + ), + ); + }, + ); + } + + Widget _buildFallbackPreview(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.link, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Link', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const Gap(6), + Text( + 'Link embed was not loaded.', + style: Theme.of(context).textTheme.labelSmall, + ).opacity(0.75), + ], + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: SelectableText( + link, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), ), ), ],