Link preview in posts

🐛 Fix link preview icon bugged when site icon is svg
This commit is contained in:
LittleSheep 2024-12-14 15:21:34 +08:00
parent 1f8d47f6c3
commit 80a66136ce
8 changed files with 194 additions and 117 deletions

View File

@ -28,7 +28,7 @@ class SnLinkPreviewProvider {
_cache[url] = meta; _cache[url] = meta;
return meta; return meta;
} catch (err) { } catch (err) {
log('[LinkPreview] Failed to fetch $url ($target)'); log('[LinkPreview] Failed to fetch $url ($target)...');
return null; return null;
} }
} }

View File

@ -5,6 +5,8 @@ part 'link.freezed.dart';
@freezed @freezed
class SnLinkMeta with _$SnLinkMeta { class SnLinkMeta with _$SnLinkMeta {
const SnLinkMeta._();
const factory SnLinkMeta({ const factory SnLinkMeta({
required int id, required int id,
required DateTime createdAt, required DateTime createdAt,
@ -17,7 +19,7 @@ class SnLinkMeta with _$SnLinkMeta {
required String? image, required String? image,
required String? video, required String? video,
required String? audio, required String? audio,
required String description, required String? description,
required String? siteName, required String? siteName,
required String? type, required String? type,
}) = _SnLinkMeta; }) = _SnLinkMeta;

View File

@ -31,7 +31,7 @@ mixin _$SnLinkMeta {
String? get image => throw _privateConstructorUsedError; String? get image => throw _privateConstructorUsedError;
String? get video => throw _privateConstructorUsedError; String? get video => throw _privateConstructorUsedError;
String? get audio => throw _privateConstructorUsedError; String? get audio => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError; String? get description => throw _privateConstructorUsedError;
String? get siteName => throw _privateConstructorUsedError; String? get siteName => throw _privateConstructorUsedError;
String? get type => throw _privateConstructorUsedError; String? get type => throw _privateConstructorUsedError;
@ -63,7 +63,7 @@ abstract class $SnLinkMetaCopyWith<$Res> {
String? image, String? image,
String? video, String? video,
String? audio, String? audio,
String description, String? description,
String? siteName, String? siteName,
String? type}); String? type});
} }
@ -94,7 +94,7 @@ class _$SnLinkMetaCopyWithImpl<$Res, $Val extends SnLinkMeta>
Object? image = freezed, Object? image = freezed,
Object? video = freezed, Object? video = freezed,
Object? audio = freezed, Object? audio = freezed,
Object? description = null, Object? description = freezed,
Object? siteName = freezed, Object? siteName = freezed,
Object? type = freezed, Object? type = freezed,
}) { }) {
@ -143,10 +143,10 @@ class _$SnLinkMetaCopyWithImpl<$Res, $Val extends SnLinkMeta>
? _value.audio ? _value.audio
: audio // ignore: cast_nullable_to_non_nullable : audio // ignore: cast_nullable_to_non_nullable
as String?, as String?,
description: null == description description: freezed == description
? _value.description ? _value.description
: description // ignore: cast_nullable_to_non_nullable : description // ignore: cast_nullable_to_non_nullable
as String, as String?,
siteName: freezed == siteName siteName: freezed == siteName
? _value.siteName ? _value.siteName
: siteName // ignore: cast_nullable_to_non_nullable : siteName // ignore: cast_nullable_to_non_nullable
@ -179,7 +179,7 @@ abstract class _$$SnLinkMetaImplCopyWith<$Res>
String? image, String? image,
String? video, String? video,
String? audio, String? audio,
String description, String? description,
String? siteName, String? siteName,
String? type}); String? type});
} }
@ -208,7 +208,7 @@ class __$$SnLinkMetaImplCopyWithImpl<$Res>
Object? image = freezed, Object? image = freezed,
Object? video = freezed, Object? video = freezed,
Object? audio = freezed, Object? audio = freezed,
Object? description = null, Object? description = freezed,
Object? siteName = freezed, Object? siteName = freezed,
Object? type = freezed, Object? type = freezed,
}) { }) {
@ -257,10 +257,10 @@ class __$$SnLinkMetaImplCopyWithImpl<$Res>
? _value.audio ? _value.audio
: audio // ignore: cast_nullable_to_non_nullable : audio // ignore: cast_nullable_to_non_nullable
as String?, as String?,
description: null == description description: freezed == description
? _value.description ? _value.description
: description // ignore: cast_nullable_to_non_nullable : description // ignore: cast_nullable_to_non_nullable
as String, as String?,
siteName: freezed == siteName siteName: freezed == siteName
? _value.siteName ? _value.siteName
: siteName // ignore: cast_nullable_to_non_nullable : siteName // ignore: cast_nullable_to_non_nullable
@ -275,7 +275,7 @@ class __$$SnLinkMetaImplCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$SnLinkMetaImpl implements _SnLinkMeta { class _$SnLinkMetaImpl extends _SnLinkMeta {
const _$SnLinkMetaImpl( const _$SnLinkMetaImpl(
{required this.id, {required this.id,
required this.createdAt, required this.createdAt,
@ -290,7 +290,8 @@ class _$SnLinkMetaImpl implements _SnLinkMeta {
required this.audio, required this.audio,
required this.description, required this.description,
required this.siteName, required this.siteName,
required this.type}); required this.type})
: super._();
factory _$SnLinkMetaImpl.fromJson(Map<String, dynamic> json) => factory _$SnLinkMetaImpl.fromJson(Map<String, dynamic> json) =>
_$$SnLinkMetaImplFromJson(json); _$$SnLinkMetaImplFromJson(json);
@ -318,7 +319,7 @@ class _$SnLinkMetaImpl implements _SnLinkMeta {
@override @override
final String? audio; final String? audio;
@override @override
final String description; final String? description;
@override @override
final String? siteName; final String? siteName;
@override @override
@ -390,7 +391,7 @@ class _$SnLinkMetaImpl implements _SnLinkMeta {
} }
} }
abstract class _SnLinkMeta implements SnLinkMeta { abstract class _SnLinkMeta extends SnLinkMeta {
const factory _SnLinkMeta( const factory _SnLinkMeta(
{required final int id, {required final int id,
required final DateTime createdAt, required final DateTime createdAt,
@ -403,9 +404,10 @@ abstract class _SnLinkMeta implements SnLinkMeta {
required final String? image, required final String? image,
required final String? video, required final String? video,
required final String? audio, required final String? audio,
required final String description, required final String? description,
required final String? siteName, required final String? siteName,
required final String? type}) = _$SnLinkMetaImpl; required final String? type}) = _$SnLinkMetaImpl;
const _SnLinkMeta._() : super._();
factory _SnLinkMeta.fromJson(Map<String, dynamic> json) = factory _SnLinkMeta.fromJson(Map<String, dynamic> json) =
_$SnLinkMetaImpl.fromJson; _$SnLinkMetaImpl.fromJson;
@ -433,7 +435,7 @@ abstract class _SnLinkMeta implements SnLinkMeta {
@override @override
String? get audio; String? get audio;
@override @override
String get description; String? get description;
@override @override
String? get siteName; String? get siteName;
@override @override

View File

@ -21,7 +21,7 @@ _$SnLinkMetaImpl _$$SnLinkMetaImplFromJson(Map<String, dynamic> json) =>
image: json['image'] as String?, image: json['image'] as String?,
video: json['video'] as String?, video: json['video'] as String?,
audio: json['audio'] as String?, audio: json['audio'] as String?,
description: json['description'] as String, description: json['description'] as String?,
siteName: json['site_name'] as String?, siteName: json['site_name'] as String?,
type: json['type'] as String?, type: json['type'] as String?,
); );

View File

@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:marquee/marquee.dart'; import 'package:marquee/marquee.dart';
@ -50,100 +51,120 @@ class _LinkPreviewWidgetState extends State<LinkPreviewWidget> {
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _links children: _links.map((e) => _LinkPreviewEntry(meta: e)).toList(),
.map( );
(e) => Container( }
constraints: BoxConstraints( }
maxWidth: ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) ? double.infinity : 480,
), class _LinkPreviewEntry extends StatelessWidget {
child: GestureDetector( final SnLinkMeta meta;
child: Card(
child: Column( const _LinkPreviewEntry({
crossAxisAlignment: CrossAxisAlignment.start, super.key,
children: [ required this.meta,
if (e.image != null) });
Container(
margin: const EdgeInsets.only(bottom: 4), @override
color: Theme.of(context).colorScheme.surfaceContainer, Widget build(BuildContext context) {
child: AspectRatio( return Container(
aspectRatio: 16 / 9, constraints: BoxConstraints(
child: ClipRRect( maxWidth: ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) ? double.infinity : 480,
child: AutoResizeUniversalImage( ),
e.image!, child: GestureDetector(
fit: BoxFit.contain, child: Card(
), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
), if (meta.image != null)
SizedBox( Container(
height: 48, margin: const EdgeInsets.only(bottom: 4),
child: Row( color: Theme.of(context).colorScheme.surfaceContainer,
crossAxisAlignment: CrossAxisAlignment.center, child: AspectRatio(
children: [ aspectRatio: 16 / 9,
if (e.icon != null) child: ClipRRect(
UniversalImage( child: AutoResizeUniversalImage(
e.icon!, meta.image!,
fit: BoxFit.contain,
),
),
),
),
SizedBox(
height: 48,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (meta.icon?.isNotEmpty ?? false)
StyledWidget(
meta.icon!.endsWith('.svg')
? SvgPicture.network(meta.icon!)
: UniversalImage(
meta.icon!,
width: 36, width: 36,
height: 36, height: 36,
cacheHeight: 36, cacheHeight: 36,
cacheWidth: 36, cacheWidth: 36,
).padding(all: 4),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 24,
child: Marquee(
text: e.title ?? 'unknown'.tr(),
style: TextStyle(fontSize: 17, height: 1),
scrollAxis: Axis.horizontal,
showFadingOnlyWhenScrolling: true,
pauseAfterRound: const Duration(seconds: 3),
),
),
if (e.siteName != null)
Text(
e.siteName!,
style: TextStyle(fontSize: 13, height: 0.9),
).fontSize(11),
],
), ),
), ).padding(all: 4, right: 16),
const Gap(6), Expanded(
], child: Column(
).padding(horizontal: 16), crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 24,
child: ((meta.title?.length ?? 0) > 40)
? Marquee(
text: meta.title ?? 'unknown'.tr(),
style: TextStyle(fontSize: 17, height: 1),
scrollAxis: Axis.horizontal,
showFadingOnlyWhenScrolling: true,
pauseAfterRound: const Duration(seconds: 3),
)
: Text(
meta.title ?? 'unknown'.tr(),
style: TextStyle(fontSize: 17, height: 1),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (meta.siteName != null)
Text(
meta.siteName!,
style: TextStyle(fontSize: 13, height: 0.9),
).fontSize(11),
],
), ),
Text( ),
e.description, const Gap(6),
maxLines: 3, ],
overflow: TextOverflow.ellipsis, ).padding(horizontal: 16),
).padding(horizontal: 16),
const Gap(8),
Text(
e.url,
style: GoogleFonts.roboto(fontSize: 11, height: 0.9),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).opacity(0.75).padding(horizontal: 16),
const Gap(4),
Text(
'poweredBy'.tr(args: ['HyperNet.Reader']),
style: GoogleFonts.roboto(fontSize: 11, height: 0.9),
).opacity(0.75).padding(horizontal: 16),
const Gap(16),
],
),
),
onTap: () {
launchUrlString(e.url, mode: LaunchMode.externalApplication);
},
), ),
), if (meta.description != null)
) Text(
.toList(), meta.description!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 16, bottom: 8),
Text(
meta.url,
style: GoogleFonts.roboto(fontSize: 11, height: 0.9),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).opacity(0.75).padding(horizontal: 16),
const Gap(4),
Text(
'poweredBy'.tr(args: ['HyperNet.Reader']),
style: GoogleFonts.roboto(fontSize: 11, height: 0.9),
).opacity(0.75).padding(horizontal: 16),
const Gap(16),
],
),
),
onTap: () {
launchUrlString(meta.url, mode: LaunchMode.externalApplication);
},
),
); );
} }
} }

View File

@ -24,6 +24,7 @@ import 'package:surface/types/reaction.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/markdown_content.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:surface/widgets/post/post_comment_list.dart'; import 'package:surface/widgets/post/post_comment_list.dart';
@ -103,7 +104,7 @@ class PostItem extends StatelessWidget {
).create(); ).create();
await imageFile.writeAsBytes(capturedImage); await imageFile.writeAsBytes(capturedImage);
if(!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
await Share.shareXFiles( await Share.shareXFiles(
[XFile(imageFile.path)], [XFile(imageFile.path)],
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
@ -219,11 +220,12 @@ class PostItem extends StatelessWidget {
data: data, data: data,
isEnlarge: data.type == 'article' && showFullPost, isEnlarge: data.type == 'article' && showFullPost,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
_PostContentBody( if (data.body['content']?.isNotEmpty ?? false)
data: data, _PostContentBody(
isSelectable: showFullPost, data: data,
isEnlarge: data.type == 'article' && showFullPost, isSelectable: showFullPost,
).padding(horizontal: 16, bottom: 6), isEnlarge: data.type == 'article' && showFullPost,
).padding(horizontal: 16, bottom: 6),
if (data.repostTo != null) if (data.repostTo != null)
_PostQuoteContent(child: data.repostTo!).padding( _PostQuoteContent(child: data.repostTo!).padding(
horizontal: 12, horizontal: 12,
@ -250,6 +252,10 @@ class PostItem extends StatelessWidget {
maxHeight: 560, maxHeight: 560,
listPadding: const EdgeInsets.symmetric(horizontal: 12), listPadding: const EdgeInsets.symmetric(horizontal: 12),
), ),
if (data.body['content'] != null)
LinkPreviewWidget(
text: data.body['content'],
).padding(horizontal: 4),
Container( Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Column( child: Column(
@ -315,10 +321,11 @@ class PostShareImageWidget extends StatelessWidget {
data: data, data: data,
isEnlarge: data.type == 'article', isEnlarge: data.type == 'article',
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
_PostContentBody( if (data.body['content']?.isNotEmpty ?? false)
data: data, _PostContentBody(
isEnlarge: data.type == 'article', data: data,
).padding(horizontal: 16, bottom: 8), isEnlarge: data.type == 'article',
).padding(horizontal: 16, bottom: 8),
if (data.repostTo != null) if (data.repostTo != null)
_PostQuoteContent( _PostQuoteContent(
child: data.repostTo!, child: data.repostTo!,
@ -330,6 +337,10 @@ class PostShareImageWidget extends StatelessWidget {
data: data.preload!.attachments!, data: data.preload!.attachments!,
isFlatted: true, isFlatted: true,
).padding(horizontal: 16, bottom: 8), ).padding(horizontal: 16, bottom: 8),
if (data.body['content'] != null)
LinkPreviewWidget(
text: data.body['content'],
).padding(horizontal: 4),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -375,7 +386,7 @@ class PostShareImageWidget extends StatelessWidget {
], ],
), ),
), ),
if(data.body['content_truncated'] == true) if (data.body['content_truncated'] == true)
Text( Text(
'postImageShareReadMore'.tr(), 'postImageShareReadMore'.tr(),
style: GoogleFonts.robotoMono(fontSize: 11), style: GoogleFonts.robotoMono(fontSize: 11),

View File

@ -712,6 +712,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" version: "0.7.0"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "54900a1a1243f3c4a5506d853a2b5c2dbc38d5f27e52a52618a8054401431123"
url: "https://pub.dev"
source: hosted
version: "2.0.16"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -1242,6 +1250,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1911,6 +1927,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.1" version: "4.5.1"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7"
url: "https://pub.dev"
source: hosted
version: "1.1.15"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb"
url: "https://pub.dev"
source: hosted
version: "1.1.12"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
url: "https://pub.dev"
source: hosted
version: "1.1.16"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.0.1+24 version: 2.0.1+25
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@ -103,6 +103,7 @@ dependencies:
file_saver: ^0.2.14 file_saver: ^0.2.14
device_info_plus: ^11.2.0 device_info_plus: ^11.2.0
marquee: ^2.3.0 marquee: ^2.3.0
flutter_svg: ^2.0.16
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: