From 1f8d47f6c3585462099094c582fc8dd45be8eff5 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 14 Dec 2024 14:46:11 +0800 Subject: [PATCH] :sparkles: Link preview --- assets/translations/en-US.json | 3 +- assets/translations/zh-CN.json | 3 +- lib/main.dart | 11 +- lib/providers/link_preview.dart | 35 +++ lib/providers/websocket.dart | 2 + lib/types/link.dart | 26 ++ lib/types/link.freezed.dart | 448 +++++++++++++++++++++++++++++ lib/types/link.g.dart | 45 +++ lib/widgets/chat/chat_message.dart | 23 +- lib/widgets/link_preview.dart | 149 ++++++++++ lib/widgets/post/post_item.dart | 6 + pubspec.lock | 16 ++ pubspec.yaml | 1 + 13 files changed, 752 insertions(+), 16 deletions(-) create mode 100644 lib/providers/link_preview.dart create mode 100644 lib/types/link.dart create mode 100644 lib/types/link.freezed.dart create mode 100644 lib/types/link.g.dart create mode 100644 lib/widgets/link_preview.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 400e6a9..25a4d52 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -444,5 +444,6 @@ "postImageShareAds": "Explore posts on the Solar Network", "postShare": "Share", "postShareImage": "Share via Image", - "appInitializing": "Initializing" + "appInitializing": "Initializing", + "poweredBy": "Powered by {}" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 351deda..c29a278 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -442,5 +442,6 @@ "postImageShareAds": "来 Solar Network 探索更多有趣帖子", "postShare": "分享", "postShareImage": "分享帖图", - "appInitializing": "正在初始化" + "appInitializing": "正在初始化", + "poweredBy": "由 {} 提供支持" } diff --git a/lib/main.dart b/lib/main.dart index 82c953d..dfee45c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:surface/firebase_options.dart'; import 'package:surface/providers/channel.dart'; import 'package:surface/providers/chat_call.dart'; +import 'package:surface/providers/link_preview.dart'; import 'package:surface/providers/navigation.dart'; import 'package:surface/providers/notification.dart'; import 'package:surface/providers/post.dart'; @@ -92,6 +93,7 @@ class SolianApp extends StatelessWidget { Provider(create: (ctx) => SnAttachmentProvider(ctx)), Provider(create: (ctx) => SnPostContentProvider(ctx)), Provider(create: (ctx) => SnRelationshipProvider(ctx)), + Provider(create: (ctx) => SnLinkPreviewProvider(ctx)), ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)), ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)), ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), @@ -111,7 +113,7 @@ class SolianApp extends StatelessWidget { } class _AppDelegate extends StatelessWidget { - const _AppDelegate({super.key}); + const _AppDelegate(); @override Widget build(BuildContext context) { @@ -134,7 +136,10 @@ class _AppDelegate extends StatelessWidget { ], routerConfig: appRouter, builder: (context, child) { - return _AppSplashScreen(child: child!); + return _AppSplashScreen( + key: const Key('global-splash-screen'), + child: child!, + ); }, ); } @@ -187,7 +192,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> { body: Container( constraints: const BoxConstraints(maxWidth: 180), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Image.asset("assets/icon/icon.png", width: 64, height: 64), diff --git a/lib/providers/link_preview.dart b/lib/providers/link_preview.dart new file mode 100644 index 0000000..a238804 --- /dev/null +++ b/lib/providers/link_preview.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/types/link.dart'; + +class SnLinkPreviewProvider { + late final SnNetworkProvider _sn; + + final Map _cache = {}; + + SnLinkPreviewProvider(BuildContext context) { + _sn = context.read(); + } + + Future getLinkMeta(String url) async { + final b64 = utf8.fuse(base64Url); + final target = b64.encode(url); + if (_cache.containsKey(target)) return _cache[target]; + + log('[LinkPreview] Fetching $url ($target)'); + + try { + final resp = await _sn.client.get('/cgi/re/link/$target'); + final meta = SnLinkMeta.fromJson(resp.data); + _cache[url] = meta; + return meta; + } catch (err) { + log('[LinkPreview] Failed to fetch $url ($target)'); + return null; + } + } +} diff --git a/lib/providers/websocket.dart b/lib/providers/websocket.dart index 07a2fb8..6bfd059 100644 --- a/lib/providers/websocket.dart +++ b/lib/providers/websocket.dart @@ -26,6 +26,7 @@ class WebSocketProvider extends ChangeNotifier { } Future tryConnect() async { + if (isConnected) return; if (!_ua.isAuthorized) return; log('[WebSocket] Connecting to the server...'); @@ -76,6 +77,7 @@ class WebSocketProvider extends ChangeNotifier { if (conn != null) { conn!.sink.close(); } + conn = null; isConnected = false; notifyListeners(); } diff --git a/lib/types/link.dart b/lib/types/link.dart new file mode 100644 index 0000000..0fa4f3f --- /dev/null +++ b/lib/types/link.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'link.g.dart'; +part 'link.freezed.dart'; + +@freezed +class SnLinkMeta with _$SnLinkMeta { + const factory SnLinkMeta({ + required int id, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + required String entryId, + required String? icon, + required String url, + required String? title, + required String? image, + required String? video, + required String? audio, + required String description, + required String? siteName, + required String? type, + }) = _SnLinkMeta; + + factory SnLinkMeta.fromJson(Map json) => _$SnLinkMetaFromJson(json); +} diff --git a/lib/types/link.freezed.dart b/lib/types/link.freezed.dart new file mode 100644 index 0000000..197dcd4 --- /dev/null +++ b/lib/types/link.freezed.dart @@ -0,0 +1,448 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'link.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +SnLinkMeta _$SnLinkMetaFromJson(Map json) { + return _SnLinkMeta.fromJson(json); +} + +/// @nodoc +mixin _$SnLinkMeta { + int get id => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + DateTime get updatedAt => throw _privateConstructorUsedError; + DateTime? get deletedAt => throw _privateConstructorUsedError; + String get entryId => throw _privateConstructorUsedError; + String? get icon => throw _privateConstructorUsedError; + String get url => throw _privateConstructorUsedError; + String? get title => throw _privateConstructorUsedError; + String? get image => throw _privateConstructorUsedError; + String? get video => throw _privateConstructorUsedError; + String? get audio => throw _privateConstructorUsedError; + String get description => throw _privateConstructorUsedError; + String? get siteName => throw _privateConstructorUsedError; + String? get type => throw _privateConstructorUsedError; + + /// Serializes this SnLinkMeta to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SnLinkMeta + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SnLinkMetaCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SnLinkMetaCopyWith<$Res> { + factory $SnLinkMetaCopyWith( + SnLinkMeta value, $Res Function(SnLinkMeta) then) = + _$SnLinkMetaCopyWithImpl<$Res, SnLinkMeta>; + @useResult + $Res call( + {int id, + DateTime createdAt, + DateTime updatedAt, + DateTime? deletedAt, + String entryId, + String? icon, + String url, + String? title, + String? image, + String? video, + String? audio, + String description, + String? siteName, + String? type}); +} + +/// @nodoc +class _$SnLinkMetaCopyWithImpl<$Res, $Val extends SnLinkMeta> + implements $SnLinkMetaCopyWith<$Res> { + _$SnLinkMetaCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SnLinkMeta + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? createdAt = null, + Object? updatedAt = null, + Object? deletedAt = freezed, + Object? entryId = null, + Object? icon = freezed, + Object? url = null, + Object? title = freezed, + Object? image = freezed, + Object? video = freezed, + Object? audio = freezed, + Object? description = null, + Object? siteName = freezed, + Object? type = freezed, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + deletedAt: freezed == deletedAt + ? _value.deletedAt + : deletedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + entryId: null == entryId + ? _value.entryId + : entryId // ignore: cast_nullable_to_non_nullable + as String, + icon: freezed == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String?, + url: null == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + image: freezed == image + ? _value.image + : image // ignore: cast_nullable_to_non_nullable + as String?, + video: freezed == video + ? _value.video + : video // ignore: cast_nullable_to_non_nullable + as String?, + audio: freezed == audio + ? _value.audio + : audio // ignore: cast_nullable_to_non_nullable + as String?, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + siteName: freezed == siteName + ? _value.siteName + : siteName // ignore: cast_nullable_to_non_nullable + as String?, + type: freezed == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SnLinkMetaImplCopyWith<$Res> + implements $SnLinkMetaCopyWith<$Res> { + factory _$$SnLinkMetaImplCopyWith( + _$SnLinkMetaImpl value, $Res Function(_$SnLinkMetaImpl) then) = + __$$SnLinkMetaImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {int id, + DateTime createdAt, + DateTime updatedAt, + DateTime? deletedAt, + String entryId, + String? icon, + String url, + String? title, + String? image, + String? video, + String? audio, + String description, + String? siteName, + String? type}); +} + +/// @nodoc +class __$$SnLinkMetaImplCopyWithImpl<$Res> + extends _$SnLinkMetaCopyWithImpl<$Res, _$SnLinkMetaImpl> + implements _$$SnLinkMetaImplCopyWith<$Res> { + __$$SnLinkMetaImplCopyWithImpl( + _$SnLinkMetaImpl _value, $Res Function(_$SnLinkMetaImpl) _then) + : super(_value, _then); + + /// Create a copy of SnLinkMeta + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? createdAt = null, + Object? updatedAt = null, + Object? deletedAt = freezed, + Object? entryId = null, + Object? icon = freezed, + Object? url = null, + Object? title = freezed, + Object? image = freezed, + Object? video = freezed, + Object? audio = freezed, + Object? description = null, + Object? siteName = freezed, + Object? type = freezed, + }) { + return _then(_$SnLinkMetaImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + deletedAt: freezed == deletedAt + ? _value.deletedAt + : deletedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + entryId: null == entryId + ? _value.entryId + : entryId // ignore: cast_nullable_to_non_nullable + as String, + icon: freezed == icon + ? _value.icon + : icon // ignore: cast_nullable_to_non_nullable + as String?, + url: null == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + image: freezed == image + ? _value.image + : image // ignore: cast_nullable_to_non_nullable + as String?, + video: freezed == video + ? _value.video + : video // ignore: cast_nullable_to_non_nullable + as String?, + audio: freezed == audio + ? _value.audio + : audio // ignore: cast_nullable_to_non_nullable + as String?, + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + siteName: freezed == siteName + ? _value.siteName + : siteName // ignore: cast_nullable_to_non_nullable + as String?, + type: freezed == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SnLinkMetaImpl implements _SnLinkMeta { + const _$SnLinkMetaImpl( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.deletedAt, + required this.entryId, + required this.icon, + required this.url, + required this.title, + required this.image, + required this.video, + required this.audio, + required this.description, + required this.siteName, + required this.type}); + + factory _$SnLinkMetaImpl.fromJson(Map json) => + _$$SnLinkMetaImplFromJson(json); + + @override + final int id; + @override + final DateTime createdAt; + @override + final DateTime updatedAt; + @override + final DateTime? deletedAt; + @override + final String entryId; + @override + final String? icon; + @override + final String url; + @override + final String? title; + @override + final String? image; + @override + final String? video; + @override + final String? audio; + @override + final String description; + @override + final String? siteName; + @override + final String? type; + + @override + String toString() { + return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SnLinkMetaImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt) && + (identical(other.deletedAt, deletedAt) || + other.deletedAt == deletedAt) && + (identical(other.entryId, entryId) || other.entryId == entryId) && + (identical(other.icon, icon) || other.icon == icon) && + (identical(other.url, url) || other.url == url) && + (identical(other.title, title) || other.title == title) && + (identical(other.image, image) || other.image == image) && + (identical(other.video, video) || other.video == video) && + (identical(other.audio, audio) || other.audio == audio) && + (identical(other.description, description) || + other.description == description) && + (identical(other.siteName, siteName) || + other.siteName == siteName) && + (identical(other.type, type) || other.type == type)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + createdAt, + updatedAt, + deletedAt, + entryId, + icon, + url, + title, + image, + video, + audio, + description, + siteName, + type); + + /// Create a copy of SnLinkMeta + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith => + __$$SnLinkMetaImplCopyWithImpl<_$SnLinkMetaImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SnLinkMetaImplToJson( + this, + ); + } +} + +abstract class _SnLinkMeta implements SnLinkMeta { + const factory _SnLinkMeta( + {required final int id, + required final DateTime createdAt, + required final DateTime updatedAt, + required final DateTime? deletedAt, + required final String entryId, + required final String? icon, + required final String url, + required final String? title, + required final String? image, + required final String? video, + required final String? audio, + required final String description, + required final String? siteName, + required final String? type}) = _$SnLinkMetaImpl; + + factory _SnLinkMeta.fromJson(Map json) = + _$SnLinkMetaImpl.fromJson; + + @override + int get id; + @override + DateTime get createdAt; + @override + DateTime get updatedAt; + @override + DateTime? get deletedAt; + @override + String get entryId; + @override + String? get icon; + @override + String get url; + @override + String? get title; + @override + String? get image; + @override + String? get video; + @override + String? get audio; + @override + String get description; + @override + String? get siteName; + @override + String? get type; + + /// Create a copy of SnLinkMeta + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/types/link.g.dart b/lib/types/link.g.dart new file mode 100644 index 0000000..d813133 --- /dev/null +++ b/lib/types/link.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'link.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SnLinkMetaImpl _$$SnLinkMetaImplFromJson(Map json) => + _$SnLinkMetaImpl( + id: (json['id'] as num).toInt(), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + entryId: json['entry_id'] as String, + icon: json['icon'] as String?, + url: json['url'] as String, + title: json['title'] as String?, + image: json['image'] as String?, + video: json['video'] as String?, + audio: json['audio'] as String?, + description: json['description'] as String, + siteName: json['site_name'] as String?, + type: json['type'] as String?, + ); + +Map _$$SnLinkMetaImplToJson(_$SnLinkMetaImpl instance) => + { + 'id': instance.id, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + 'entry_id': instance.entryId, + 'icon': instance.icon, + 'url': instance.url, + 'title': instance.title, + 'image': instance.image, + 'video': instance.video, + 'audio': instance.audio, + 'description': instance.description, + 'site_name': instance.siteName, + 'type': instance.type, + }; diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index 7d81dfc..d7b19f5 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart'; import 'package:surface/types/chat.dart'; import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/attachment/attachment_list.dart'; +import 'package:surface/widgets/link_preview.dart'; import 'package:surface/widgets/markdown_content.dart'; import 'package:swipe_to/swipe_to.dart'; @@ -22,6 +23,7 @@ class ChatMessage extends StatelessWidget { final Function(SnChatMessage)? onReply; final Function(SnChatMessage)? onEdit; final Function(SnChatMessage)? onDelete; + const ChatMessage({ super.key, required this.data, @@ -63,7 +65,7 @@ class ChatMessage extends StatelessWidget { onReply!(data); }, ), - if (isOwner && onEdit != null) + if (isOwner && data.type == 'messages.new' && onEdit != null) MenuItem( label: 'edit'.tr(), icon: Symbols.edit, @@ -71,7 +73,7 @@ class ChatMessage extends StatelessWidget { onEdit!(data); }, ), - if (isOwner && onDelete != null) + if (isOwner && data.type == 'messages.new' && onDelete != null) MenuItem( label: 'delete'.tr(), icon: Symbols.delete, @@ -109,9 +111,7 @@ class ChatMessage extends StatelessWidget { radius: 12, ).padding(right: 6), Text( - (data.sender.nick?.isNotEmpty ?? false) - ? data.sender.nick! - : user?.nick ?? 'unknown', + (data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown', ).bold(), const Gap(6), Text( @@ -123,8 +123,7 @@ class ChatMessage extends StatelessWidget { if (data.preload?.quoteEvent != null) StyledWidget(Container( decoration: BoxDecoration( - borderRadius: - const BorderRadius.all(Radius.circular(8)), + borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border.all( color: Theme.of(context).dividerColor, width: 1, @@ -153,6 +152,8 @@ class ChatMessage extends StatelessWidget { ) ], ).opacity(isPending ? 0.5 : 1), + if (data.body['text'] != null && (data.body['text']?.isNotEmpty ?? false)) + LinkPreviewWidget(text: data.body['text']!), if (data.preload?.attachments?.isNotEmpty ?? false) AttachmentList( data: data.preload!.attachments!, @@ -161,10 +162,7 @@ class ChatMessage extends StatelessWidget { maxHeight: 520, listPadding: const EdgeInsets.only(top: 8), ), - if (!hasMerged && !isCompact) - const Gap(12) - else if (!isCompact) - const Gap(6), + if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6), ], ), ), @@ -174,6 +172,7 @@ class ChatMessage extends StatelessWidget { class _ChatMessageText extends StatelessWidget { final SnChatMessage data; + const _ChatMessageText({super.key, required this.data}); @override @@ -184,6 +183,7 @@ class _ChatMessageText extends StatelessWidget { children: [ MarkdownTextContent( content: data.body['text'], + isSelectable: true, isAutoWarp: true, ), if (data.updatedAt != data.createdAt) @@ -212,6 +212,7 @@ class _ChatMessageText extends StatelessWidget { class _ChatMessageSystemNotify extends StatelessWidget { final SnChatMessage data; + const _ChatMessageSystemNotify({super.key, required this.data}); String _formatDuration(Duration duration) { diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart new file mode 100644 index 0000000..29bd10b --- /dev/null +++ b/lib/widgets/link_preview.dart @@ -0,0 +1,149 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:marquee/marquee.dart'; +import 'package:provider/provider.dart'; +import 'package:responsive_framework/responsive_framework.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:surface/types/link.dart'; +import 'package:surface/widgets/universal_image.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../providers/link_preview.dart'; + +class LinkPreviewWidget extends StatefulWidget { + final String text; + + const LinkPreviewWidget({super.key, required this.text}); + + @override + State createState() => _LinkPreviewWidgetState(); +} + +class _LinkPreviewWidgetState extends State { + final List _links = List.empty(growable: true); + + Future _getLinkMeta() async { + final linkRegex = RegExp(r'https?:\/\/[^\s/$.?#].[^\s]*'); + final links = linkRegex.allMatches(widget.text).map((e) => e.group(0)).toSet(); + + final lp = context.read(); + + final List> futures = links.where((e) => e != null).map((e) => lp.getLinkMeta(e!)).toList(); + final results = await Future.wait(futures); + + _links.addAll(results.where((e) => e != null).map((e) => e!).toList()); + if (_links.isNotEmpty && mounted) setState(() {}); + } + + @override + void initState() { + super.initState(); + _getLinkMeta(); + } + + @override + Widget build(BuildContext context) { + if (_links.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: _links + .map( + (e) => Container( + constraints: BoxConstraints( + maxWidth: ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) ? double.infinity : 480, + ), + child: GestureDetector( + child: Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (e.image != null) + Container( + margin: const EdgeInsets.only(bottom: 4), + color: Theme.of(context).colorScheme.surfaceContainer, + child: AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + child: AutoResizeUniversalImage( + e.image!, + fit: BoxFit.contain, + ), + ), + ), + ), + SizedBox( + height: 48, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (e.icon != null) + UniversalImage( + e.icon!, + width: 36, + height: 36, + cacheHeight: 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), + ], + ), + ), + const Gap(6), + ], + ).padding(horizontal: 16), + ), + Text( + e.description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ).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); + }, + ), + ), + ) + .toList(), + ); + } +} diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index ea462f9..611e793 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -132,6 +132,7 @@ class PostItem extends StatelessWidget { _PostContentHeader( data: data, isAuthor: isAuthor, + isRelativeDate: !showFullPost, onShare: () => _doShare(context), onShareImage: () => _doShareViaPicture(context), onDeleted: () { @@ -204,6 +205,7 @@ class PostItem extends StatelessWidget { children: [ _PostContentHeader( isAuthor: isAuthor, + isRelativeDate: !showFullPost, data: data, showMenu: showMenu, onShare: () => _doShare(context), @@ -219,6 +221,7 @@ class PostItem extends StatelessWidget { ).padding(horizontal: 16, bottom: 8), _PostContentBody( data: data, + isSelectable: showFullPost, isEnlarge: data.type == 'article' && showFullPost, ).padding(horizontal: 16, bottom: 6), if (data.repostTo != null) @@ -850,16 +853,19 @@ class _PostContentHeader extends StatelessWidget { class _PostContentBody extends StatelessWidget { final SnPost data; final bool isEnlarge; + final bool isSelectable; const _PostContentBody({ required this.data, this.isEnlarge = false, + this.isSelectable = false, }); @override Widget build(BuildContext context) { if (data.body['content'] == null) return const SizedBox.shrink(); return MarkdownTextContent( + isSelectable: isSelectable, textScaler: isEnlarge ? TextScaler.linear(1.1) : null, content: data.body['content'], attachments: data.preload?.attachments, diff --git a/pubspec.lock b/pubspec.lock index 3a63e0e..c111153 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -454,6 +454,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + fading_edge_scrollview: + dependency: transitive + description: + name: fading_edge_scrollview + sha256: "1f84fe3ea8e251d00d5735e27502a6a250e4aa3d3b330d3fdcb475af741464ef" + url: "https://pub.dev" + source: hosted + version: "4.1.1" fake_async: dependency: transitive description: @@ -1050,6 +1058,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.2" + marquee: + dependency: "direct main" + description: + name: marquee + sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862 + url: "https://pub.dev" + source: hosted + version: "2.3.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bea5314..571297d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -102,6 +102,7 @@ dependencies: qr_flutter: ^4.1.0 file_saver: ^0.2.14 device_info_plus: ^11.2.0 + marquee: ^2.3.0 dev_dependencies: flutter_test: