diff --git a/lib/models/embed.dart b/lib/models/embed.dart new file mode 100644 index 0000000..7e8329d --- /dev/null +++ b/lib/models/embed.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'embed.freezed.dart'; +part 'embed.g.dart'; + +@freezed +sealed class SnEmbedLink with _$SnEmbedLink { + const factory SnEmbedLink({ + @JsonKey(name: 'Type') required String type, + @JsonKey(name: 'Url') required String url, + @JsonKey(name: 'Title') required String title, + @JsonKey(name: 'Description') required String description, + @JsonKey(name: 'ImageUrl') required String imageUrl, + @JsonKey(name: 'FaviconUrl') required String faviconUrl, + @JsonKey(name: 'SiteName') required String siteName, + @JsonKey(name: 'ContentType') required String contentType, + @JsonKey(name: 'Author') required dynamic author, + @JsonKey(name: 'PublishedDate') required dynamic publishedDate, + }) = _SnEmbedLink; + + factory SnEmbedLink.fromJson(Map json) => + _$SnEmbedLinkFromJson(json); +} diff --git a/lib/models/embed.freezed.dart b/lib/models/embed.freezed.dart new file mode 100644 index 0000000..cc4b0c5 --- /dev/null +++ b/lib/models/embed.freezed.dart @@ -0,0 +1,175 @@ +// dart format width=80 +// 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 'embed.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SnEmbedLink { + +@JsonKey(name: 'Type') String get type;@JsonKey(name: 'Url') String get url;@JsonKey(name: 'Title') String get title;@JsonKey(name: 'Description') String get description;@JsonKey(name: 'ImageUrl') String get imageUrl;@JsonKey(name: 'FaviconUrl') String get faviconUrl;@JsonKey(name: 'SiteName') String get siteName;@JsonKey(name: 'ContentType') String get contentType;@JsonKey(name: 'Author') dynamic get author;@JsonKey(name: 'PublishedDate') dynamic get publishedDate; +/// Create a copy of SnEmbedLink +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnEmbedLinkCopyWith get copyWith => _$SnEmbedLinkCopyWithImpl(this as SnEmbedLink, _$identity); + + /// Serializes this SnEmbedLink to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnEmbedLink&&(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)&&const DeepCollectionEquality().equals(other.author, author)&&const DeepCollectionEquality().equals(other.publishedDate, publishedDate)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,url,title,description,imageUrl,faviconUrl,siteName,contentType,const DeepCollectionEquality().hash(author),const DeepCollectionEquality().hash(publishedDate)); + +@override +String toString() { + return 'SnEmbedLink(type: $type, url: $url, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl, siteName: $siteName, contentType: $contentType, author: $author, publishedDate: $publishedDate)'; +} + + +} + +/// @nodoc +abstract mixin class $SnEmbedLinkCopyWith<$Res> { + factory $SnEmbedLinkCopyWith(SnEmbedLink value, $Res Function(SnEmbedLink) _then) = _$SnEmbedLinkCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: 'Type') String type,@JsonKey(name: 'Url') String url,@JsonKey(name: 'Title') String title,@JsonKey(name: 'Description') String description,@JsonKey(name: 'ImageUrl') String imageUrl,@JsonKey(name: 'FaviconUrl') String faviconUrl,@JsonKey(name: 'SiteName') String siteName,@JsonKey(name: 'ContentType') String contentType,@JsonKey(name: 'Author') dynamic author,@JsonKey(name: 'PublishedDate') dynamic publishedDate +}); + + + + +} +/// @nodoc +class _$SnEmbedLinkCopyWithImpl<$Res> + implements $SnEmbedLinkCopyWith<$Res> { + _$SnEmbedLinkCopyWithImpl(this._self, this._then); + + final SnEmbedLink _self; + final $Res Function(SnEmbedLink) _then; + +/// Create a copy of SnEmbedLink +/// 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 = null,Object? imageUrl = null,Object? faviconUrl = null,Object? siteName = null,Object? contentType = null,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: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,imageUrl: null == 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: null == 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 dynamic,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable +as dynamic, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnEmbedLink implements SnEmbedLink { + const _SnEmbedLink({@JsonKey(name: 'Type') required this.type, @JsonKey(name: 'Url') required this.url, @JsonKey(name: 'Title') required this.title, @JsonKey(name: 'Description') required this.description, @JsonKey(name: 'ImageUrl') required this.imageUrl, @JsonKey(name: 'FaviconUrl') required this.faviconUrl, @JsonKey(name: 'SiteName') required this.siteName, @JsonKey(name: 'ContentType') required this.contentType, @JsonKey(name: 'Author') required this.author, @JsonKey(name: 'PublishedDate') required this.publishedDate}); + factory _SnEmbedLink.fromJson(Map json) => _$SnEmbedLinkFromJson(json); + +@override@JsonKey(name: 'Type') final String type; +@override@JsonKey(name: 'Url') final String url; +@override@JsonKey(name: 'Title') final String title; +@override@JsonKey(name: 'Description') final String description; +@override@JsonKey(name: 'ImageUrl') final String imageUrl; +@override@JsonKey(name: 'FaviconUrl') final String faviconUrl; +@override@JsonKey(name: 'SiteName') final String siteName; +@override@JsonKey(name: 'ContentType') final String contentType; +@override@JsonKey(name: 'Author') final dynamic author; +@override@JsonKey(name: 'PublishedDate') final dynamic publishedDate; + +/// Create a copy of SnEmbedLink +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnEmbedLinkCopyWith<_SnEmbedLink> get copyWith => __$SnEmbedLinkCopyWithImpl<_SnEmbedLink>(this, _$identity); + +@override +Map toJson() { + return _$SnEmbedLinkToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnEmbedLink&&(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)&&const DeepCollectionEquality().equals(other.author, author)&&const DeepCollectionEquality().equals(other.publishedDate, publishedDate)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,url,title,description,imageUrl,faviconUrl,siteName,contentType,const DeepCollectionEquality().hash(author),const DeepCollectionEquality().hash(publishedDate)); + +@override +String toString() { + return 'SnEmbedLink(type: $type, url: $url, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl, siteName: $siteName, contentType: $contentType, author: $author, publishedDate: $publishedDate)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnEmbedLinkCopyWith<$Res> implements $SnEmbedLinkCopyWith<$Res> { + factory _$SnEmbedLinkCopyWith(_SnEmbedLink value, $Res Function(_SnEmbedLink) _then) = __$SnEmbedLinkCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: 'Type') String type,@JsonKey(name: 'Url') String url,@JsonKey(name: 'Title') String title,@JsonKey(name: 'Description') String description,@JsonKey(name: 'ImageUrl') String imageUrl,@JsonKey(name: 'FaviconUrl') String faviconUrl,@JsonKey(name: 'SiteName') String siteName,@JsonKey(name: 'ContentType') String contentType,@JsonKey(name: 'Author') dynamic author,@JsonKey(name: 'PublishedDate') dynamic publishedDate +}); + + + + +} +/// @nodoc +class __$SnEmbedLinkCopyWithImpl<$Res> + implements _$SnEmbedLinkCopyWith<$Res> { + __$SnEmbedLinkCopyWithImpl(this._self, this._then); + + final _SnEmbedLink _self; + final $Res Function(_SnEmbedLink) _then; + +/// Create a copy of SnEmbedLink +/// 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 = null,Object? imageUrl = null,Object? faviconUrl = null,Object? siteName = null,Object? contentType = null,Object? author = freezed,Object? publishedDate = freezed,}) { + return _then(_SnEmbedLink( +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: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,imageUrl: null == 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: null == 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 dynamic,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable +as dynamic, + )); +} + + +} + +// dart format on diff --git a/lib/models/embed.g.dart b/lib/models/embed.g.dart new file mode 100644 index 0000000..e6a2ad3 --- /dev/null +++ b/lib/models/embed.g.dart @@ -0,0 +1,34 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'embed.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SnEmbedLink _$SnEmbedLinkFromJson(Map json) => _SnEmbedLink( + type: json['Type'] as String, + url: json['Url'] as String, + title: json['Title'] as String, + description: json['Description'] as String, + imageUrl: json['ImageUrl'] as String, + faviconUrl: json['FaviconUrl'] as String, + siteName: json['SiteName'] as String, + contentType: json['ContentType'] as String, + author: json['Author'], + publishedDate: json['PublishedDate'], +); + +Map _$SnEmbedLinkToJson(_SnEmbedLink instance) => + { + 'Type': instance.type, + 'Url': instance.url, + 'Title': instance.title, + 'Description': instance.description, + 'ImageUrl': instance.imageUrl, + 'FaviconUrl': instance.faviconUrl, + 'SiteName': instance.siteName, + 'ContentType': instance.contentType, + 'Author': instance.author, + 'PublishedDate': instance.publishedDate, + }; diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index 4edcca1..e4da507 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -8,12 +9,14 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; import 'package:island/models/chat.dart'; +import 'package:island/models/embed.dart'; import 'package:island/pods/call.dart'; import 'package:island/screens/chat/room.dart'; import 'package:island/widgets/account/account_pfc.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/embed/link.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -227,6 +230,20 @@ class MessageItem extends HookConsumerWidget { ).padding(vertical: 4); }, ), + if (remoteMessage.meta['embeds'] != null) + ...((remoteMessage.meta['embeds'] as List) + .where((embed) => embed['Type'] == 'link') + .map((embed) => SnEmbedLink.fromJson(embed as Map)) + .map((link) => LayoutBuilder( + builder: (context, constraints) { + return EmbedLinkWidget( + link: link, + maxWidth: math.min(constraints.maxWidth, 480), + margin: const EdgeInsets.symmetric(vertical: 4), + ); + }, + )) + .toList()), if (progress != null && progress!.isNotEmpty) Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/widgets/content/embed/link.dart b/lib/widgets/content/embed/link.dart new file mode 100644 index 0000000..ca3ecf3 --- /dev/null +++ b/lib/widgets/content/embed/link.dart @@ -0,0 +1,209 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:island/models/embed.dart'; +import 'package:island/widgets/content/image.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class EmbedLinkWidget extends StatelessWidget { + final SnEmbedLink link; + final double? maxWidth; + final EdgeInsetsGeometry? margin; + + const EmbedLinkWidget({ + super.key, + required this.link, + this.maxWidth, + this.margin, + }); + + Future _launchUrl() async { + final uri = Uri.parse(link.url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + width: maxWidth, + margin: margin ?? const EdgeInsets.symmetric(vertical: 8), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: _launchUrl, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Preview Image + if (link.imageUrl.isNotEmpty) + AspectRatio( + aspectRatio: 16 / 9, + child: UniversalImage(uri: link.imageUrl, fit: BoxFit.cover), + ), + + // Content + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Site info row + Row( + children: [ + // Favicon + if (link.faviconUrl.isNotEmpty) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + uri: link.faviconUrl, + width: 16, + height: 16, + fit: BoxFit.cover, + ), + ), + const Gap(8), + ] else ...[ + Icon( + Symbols.link, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const Gap(8), + ], + + // Site name + Expanded( + child: Text( + link.siteName.isNotEmpty + ? link.siteName + : Uri.parse(link.url).host, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // External link icon + Icon( + Symbols.open_in_new, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + + const Gap(8), + + // Title + if (link.title.isNotEmpty) ...[ + Text( + link.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Gap(4), + ], + + // Description + if (link.description.isNotEmpty) ...[ + Text( + link.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const Gap(8), + ], + + // URL + Text( + link.url, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + decoration: TextDecoration.underline, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + // Author and publish date + if (link.author != null || link.publishedDate != null) ...[ + const Gap(8), + Row( + children: [ + if (link.author != null) ...[ + Icon( + Symbols.person, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const Gap(4), + Text( + link.author.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (link.author != null && link.publishedDate != null) + const Gap(16), + if (link.publishedDate != null) ...[ + Icon( + Symbols.schedule, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const Gap(4), + Text( + _formatDate(link.publishedDate), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + String _formatDate(dynamic date) { + if (date == null) return ''; + + try { + DateTime dateTime; + if (date is String) { + dateTime = DateTime.parse(date); + } else if (date is DateTime) { + dateTime = date; + } else { + return date.toString(); + } + + return DateFormat.yMMMd().format(dateTime); + } catch (e) { + return date.toString(); + } + } +} diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index cbca442..85efaf1 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -7,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'dart:math' as math; +import 'package:island/models/embed.dart'; import 'package:island/models/post.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; @@ -18,6 +18,7 @@ import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/embed/link.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/post/post_replies_sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -228,6 +229,21 @@ class PostItem extends HookConsumerWidget { kWideScreenWidth - 160, ), ).padding(top: 4), + // Render embed links + if (item.meta?['embeds'] != null) + ...((item.meta!['embeds'] as List) + .where((embed) => embed['Type'] == 'link') + .map( + (embedData) => EmbedLinkWidget( + link: SnEmbedLink.fromJson( + embedData as Map, + ), + maxWidth: math.min( + MediaQuery.of(context).size.width * 0.85, + kWideScreenWidth - 160, + ), + ).padding(top: 4), + )), ], ), onTap: () {