Able to render fediverse posts

This commit is contained in:
2026-01-01 01:47:09 +08:00
parent b3ae4ab36f
commit adb231278c
15 changed files with 814 additions and 136 deletions

View File

@@ -115,10 +115,9 @@ class ComposeInfoBanner extends StatelessWidget {
const Gap(8),
CompactReferencePost(
post: post,
onTap:
onReferencePostTap != null
? () => onReferencePostTap!(context, post)
: null,
onTap: onReferencePostTap != null
? () => onReferencePostTap!(context, post)
: null,
),
],
).padding(all: 16),
@@ -133,6 +132,58 @@ class CompactReferencePost extends StatelessWidget {
const CompactReferencePost({super.key, required this.post, this.onTap});
Widget _buildProfilePicture(BuildContext context) {
// Handle publisher case
if (post.publisher != null) {
return ProfilePictureWidget(
fileId: post.publisher!.picture?.id,
radius: 16,
);
}
// Handle actor case
if (post.actor != null) {
final avatarUrl = post.actor!.avatarUrl;
if (avatarUrl != null) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
avatarUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Symbols.account_circle,
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
);
},
),
),
);
}
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: 16);
}
String _getDisplayName() {
// Handle publisher case
if (post.publisher != null) {
return post.publisher!.nick;
}
// Handle actor case
if (post.actor != null) {
return post.actor!.displayName ?? post.actor!.username ?? 'Unknown';
}
return 'Unknown';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -148,17 +199,14 @@ class CompactReferencePost extends StatelessWidget {
),
child: Row(
children: [
ProfilePictureWidget(
fileId: post.publisher.picture?.id,
radius: 16,
),
_buildProfilePicture(context),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.publisher.nick,
_getDisplayName(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,

View File

@@ -16,6 +16,59 @@ class PostAwardSheet extends HookConsumerWidget {
final SnPost post;
const PostAwardSheet({super.key, required this.post});
Widget _buildProfilePicture(BuildContext context, {double radius = 16}) {
// Handle publisher case
if (post.publisher != null) {
return ProfilePictureWidget(
file:
post.publisher!.picture ?? post.publisher!.account?.profile.picture,
radius: radius,
);
}
// Handle actor case
if (post.actor != null) {
final avatarUrl = post.actor!.avatarUrl;
if (avatarUrl != null) {
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(radius),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image.network(
avatarUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Symbols.account_circle,
size: radius,
color: Theme.of(context).colorScheme.onPrimaryContainer,
);
},
),
),
);
}
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: radius);
}
String _getPublisherName() {
// Handle publisher case
if (post.publisher != null) {
return post.publisher!.name;
}
// Handle actor case
if (post.actor != null) {
return post.actor!.username ?? 'Unknown';
}
return 'Unknown';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final messageController = useTextEditingController();
@@ -97,14 +150,13 @@ class PostAwardSheet extends HookConsumerWidget {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed:
() => _submitAward(
context,
ref,
messageController,
amountController,
selectedAttitude.value,
),
onPressed: () => _submitAward(
context,
ref,
messageController,
amountController,
selectedAttitude.value,
),
icon: const Icon(Symbols.star),
label: Text('awardSubmit'.tr()),
),
@@ -157,12 +209,12 @@ class PostAwardSheet extends HookConsumerWidget {
spacing: 6,
children: [
Text(
'awardByPublisher'.tr(args: ['@${post.publisher.name}']),
'awardByPublisher'.tr(args: ['@${_getPublisherName()}']),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
ProfilePictureWidget(file: post.publisher.picture, radius: 8),
_buildProfilePicture(context, radius: 8),
],
),
],

View File

@@ -90,7 +90,7 @@ class PostActionableItem extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
final isAuthor = useMemoized(
() => user.value != null && user.value?.id == item.publisher.accountId,
() => user.value != null && item.publisher?.accountId == user.value?.id,
[user],
);

View File

@@ -196,8 +196,8 @@ class PostItemScreenshot extends ConsumerWidget {
children: [
ProfilePictureWidget(
file:
post.publisher.picture ??
post.publisher.account?.profile.picture,
post.publisher?.picture ??
post.publisher?.account?.profile.picture,
radius: 12,
).padding(top: 4),
if (post.content?.isNotEmpty ?? false)

View File

@@ -6,10 +6,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:html2md/html2md.dart' as html2md;
import 'package:island/models/account.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/account/account_name.dart';
import 'package:island/widgets/activitypub/actor_profile.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_file_collection.dart';
import 'package:island/widgets/content/cloud_files.dart';
@@ -24,6 +27,14 @@ part 'post_shared.g.dart';
const kMessageEnableEmbedTypes = ['text', 'messages.new'];
/// Converts HTML content to markdown if contentType indicates HTML (contentType == 1)
String _convertContentToMarkdown(SnPost post) {
if (post.contentType == 1 && post.content != null) {
return html2md.convert(post.content!);
}
return post.content ?? '';
}
class RepliesState {
final List<SnPost> posts;
final bool loading;
@@ -120,6 +131,27 @@ class PostReplyPreview extends HookConsumerWidget {
this.onOpen,
});
Widget _buildProfilePicture(
BuildContext context,
SnPost post, {
double radius = 16,
}) {
// Handle publisher case
if (post.publisher != null) {
return ProfilePictureWidget(
file:
post.publisher!.picture ?? post.publisher!.account?.profile.picture,
radius: radius,
);
}
// Handle actor case
if (post.actor != null) {
return ActorAvatarWidget(actor: post.actor!, radius: radius);
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: radius);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final repliesState = ref.watch(repliesProvider(parent.id));
@@ -157,16 +189,15 @@ class PostReplyPreview extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
ProfilePictureWidget(
file:
post.publisher.picture ??
post.publisher.account?.profile.picture,
_buildProfilePicture(
context,
post,
radius: 12,
).padding(top: 4),
if (post.content?.isNotEmpty ?? false)
Expanded(
child: MarkdownTextContent(
content: post.content!,
content: _convertContentToMarkdown(post),
attachments: post.attachments,
).padding(top: 2),
)
@@ -244,16 +275,15 @@ class PostReplyPreview extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
ProfilePictureWidget(
file:
data.value?.publisher.picture ??
data.value?.publisher.account?.profile.picture,
_buildProfilePicture(
context,
data.value!,
radius: 12,
).padding(top: 4),
if (data.value?.content?.isNotEmpty ?? false)
Expanded(
child: MarkdownTextContent(
content: data.value!.content!,
content: _convertContentToMarkdown(data.value!),
attachments: data.value!.attachments,
),
)
@@ -408,6 +438,38 @@ class ReferencedPostWidget extends StatelessWidget {
this.renderingPadding = EdgeInsets.zero,
});
Widget _buildProfilePicture(
BuildContext context,
SnPost post, {
double radius = 16,
}) {
// Handle publisher case
if (post.publisher != null) {
return ProfilePictureWidget(
fileId: post.publisher!.picture?.id,
radius: radius,
);
}
// Handle actor case
if (post.actor != null) {
return ActorAvatarWidget(actor: post.actor!, radius: radius);
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: radius);
}
String _getDisplayName(SnPost post) {
// Handle publisher case
if (post.publisher != null) {
return post.publisher!.nick;
}
// Handle actor case
if (post.actor != null) {
return post.actor!.displayName ?? post.actor!.username ?? 'Unknown';
}
return 'Unknown';
}
@override
Widget build(BuildContext context) {
final referencePost = item.repliedPost ?? item.forwardedPost;
@@ -479,17 +541,14 @@ class ReferencedPostWidget extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePictureWidget(
fileId: referencePost!.publisher.picture?.id,
radius: 16,
),
_buildProfilePicture(context, referencePost!, radius: 16),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
referencePost.publisher.nick,
_getDisplayName(referencePost),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
@@ -541,7 +600,7 @@ class ReferencedPostWidget extends StatelessWidget {
).padding(bottom: 2),
if (referencePost.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: referencePost.content!,
content: _convertContentToMarkdown(referencePost),
textStyle: const TextStyle(fontSize: 14),
isSelectable: false,
linesMargin: referencePost.type == 0
@@ -620,6 +679,72 @@ class PostHeader extends StatelessWidget {
this.hideOverlay = false,
});
Widget _buildProfilePicture(
BuildContext context,
SnPost post, {
double radius = 16,
}) {
// Handle publisher case
if (post.publisher != null) {
return ProfilePictureWidget(
file:
post.publisher!.picture ?? post.publisher!.account?.profile.picture,
radius: radius,
borderRadius: post.publisher!.type == 0 ? null : 6,
);
}
// Handle actor case
if (post.actor != null) {
return ActorAvatarWidget(actor: post.actor!, radius: radius);
}
// Fallback
return ProfilePictureWidget(fileId: null, radius: radius);
}
String _getDisplayName(SnPost post) {
// Handle publisher case
if (post.publisher != null) {
return post.publisher!.nick;
}
// Handle actor case
if (post.actor != null) {
return post.actor!.displayName ?? post.actor!.username ?? 'unknown'.tr();
}
return 'unknown'.tr();
}
String? _getPublisherName(SnPost post) {
// Handle publisher case
if (post.publisher != null) {
return post.publisher!.name;
}
// Handle actor case
if (post.actor != null) {
return '${post.actor!.username}@${post.actor!.instance.domain}';
}
return null;
}
int _getPublisherType(SnPost post) {
// Handle publisher case
if (post.publisher != null) {
return post.publisher!.type;
}
return 0; // Default to user type
}
bool _hasAccount(SnPost post) {
return post.publisher?.account != null;
}
SnAccount? _getAccount(SnPost post) {
return post.publisher?.account;
}
SnVerificationMark? _getVerification(SnPost post) {
return post.publisher?.verification;
}
@override
Widget build(BuildContext context) {
return Column(
@@ -629,21 +754,17 @@ class PostHeader extends StatelessWidget {
spacing: 12,
children: [
GestureDetector(
onTap: isInteractive
onTap: isInteractive && _getPublisherName(item) != null
? () {
context.pushNamed(
'publisherProfile',
pathParameters: {'name': item.publisher.name},
pathParameters: {
'name': _getPublisherName(item) as String,
},
);
}
: null,
child: ProfilePictureWidget(
file:
item.publisher.picture ??
item.publisher.account?.profile.picture,
radius: 16,
borderRadius: item.publisher.type == 0 ? null : 6,
),
child: _buildProfilePicture(context, item, radius: 16),
),
Expanded(
child: Column(
@@ -656,24 +777,23 @@ class PostHeader extends StatelessWidget {
children: [
Flexible(
child:
(item.publisher.account != null &&
item.publisher.type == 0)
(_hasAccount(item) && _getPublisherType(item) == 0)
? AccountName(
hideOverlay: hideOverlay,
account: item.publisher.account!,
textOverride: item.publisher.nick,
account: _getAccount(item)!,
textOverride: _getDisplayName(item),
style: TextStyle(fontWeight: FontWeight.bold),
hideVerificationMark: true,
)
: Text(
item.publisher.nick,
_getDisplayName(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
).bold(),
),
if (item.publisher.verification != null)
if (_getVerification(item) != null)
VerificationMark(
mark: item.publisher.verification!,
mark: _getVerification(item)!,
hideOverlay: hideOverlay,
),
if (item.realm == null)
@@ -681,7 +801,7 @@ class PostHeader extends StatelessWidget {
child: isCompact
? const SizedBox.shrink()
: Text(
'@${item.publisher.name}',
'@${_getPublisherName(item) ?? 'unknown'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).fontSize(11),
@@ -891,6 +1011,17 @@ class PostBody extends ConsumerWidget {
),
);
}
if (item.fediverseUri != null) {
metadataChildren.add(
Row(
spacing: 8,
children: [
const Icon(Symbols.globe, size: 16),
Text('fediversePostDescribe'.tr()).fontSize(13),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -937,7 +1068,7 @@ class PostBody extends ConsumerWidget {
)
else
MarkdownTextContent(
content: '${item.content!}...',
content: '${_convertContentToMarkdown(item)}...',
attachments: item.attachments,
),
],
@@ -974,8 +1105,8 @@ class PostBody extends ConsumerWidget {
).padding(bottom: 4),
MarkdownTextContent(
content: item.isTruncated
? '${item.content!}...'
: item.content ?? '',
? '${_convertContentToMarkdown(item)}...'
: _convertContentToMarkdown(item),
isSelectable: isTextSelectable,
attachments: item.attachments,
),