From b583780cfc81e45da380c761e60a1934a114250c Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sat, 7 Dec 2024 17:43:44 +0800
Subject: [PATCH] :sparkles: Article thumbnail

---
 assets/translations/en.json                   |  12 +-
 assets/translations/zh.json                   |   8 +-
 lib/controllers/post_write_controller.dart    | 129 +++++++++----
 lib/main.dart                                 |   4 -
 lib/screens/post/post_detail.dart             |   1 +
 lib/screens/post/post_editor.dart             | 103 ++++-------
 .../navigation/app_drawer_navigation.dart     |   2 +-
 lib/widgets/post/post_item.dart               | 114 +++++++++---
 lib/widgets/post/post_media_pending_list.dart | 171 +++++++++++++-----
 lib/widgets/post/post_meta_editor.dart        |   4 +-
 10 files changed, 370 insertions(+), 178 deletions(-)

diff --git a/assets/translations/en.json b/assets/translations/en.json
index 5c1155a..04f1009 100644
--- a/assets/translations/en.json
+++ b/assets/translations/en.json
@@ -257,8 +257,13 @@
   "addAttachmentFromAlbum": "Add from album",
   "addAttachmentFromClipboard": "Paste file",
   "attachmentPastedImage": "Pasted Image",
-  "notificationUnread": "未读",
-  "notificationRead": "已读",
+  "attachmentInsertLink": "Insert Link",
+  "attachmentSetAsPostThumbnail": "Set as post thumbnail",
+  "attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
+  "attachmentSetThumbnail": "Set thumbnail",
+  "attachmentUpload": "Upload",
+  "notificationUnread": "Unread",
+  "notificationRead": "Read",
   "notificationMarkAllRead": "Mark all notifications as read",
   "notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.",
   "notificationMarkAllReadPrompt": {
@@ -377,5 +382,6 @@
   "accountStatus": "Status",
   "accountStatusOnline": "Online",
   "accountStatusOffline": "Offline",
-  "accountStatusLastSeen": "Last seen at {}"
+  "accountStatusLastSeen": "Last seen at {}",
+  "postArticle": "Article on the Solar Network"
 }
diff --git a/assets/translations/zh.json b/assets/translations/zh.json
index 3114711..c6d8a87 100644
--- a/assets/translations/zh.json
+++ b/assets/translations/zh.json
@@ -257,6 +257,11 @@
   "addAttachmentFromAlbum": "从相册中添加附件",
   "addAttachmentFromClipboard": "粘贴附件",
   "attachmentPastedImage": "粘贴的图片",
+  "attachmentInsertLink": "插入连接",
+  "attachmentSetAsPostThumbnail": "设置为帖子缩略图",
+  "attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
+  "attachmentSetThumbnail": "设置缩略图",
+  "attachmentUpload": "上传",
   "notificationUnread": "未读",
   "notificationRead": "已读",
   "notificationMarkAllRead": "已读所有通知",
@@ -377,5 +382,6 @@
   "accountStatus": "状态",
   "accountStatusOnline": "在线",
   "accountStatusOffline": "离线",
-  "accountStatusLastSeen": "最后一次在 {} 上线"
+  "accountStatusLastSeen": "最后一次在 {} 上线",
+  "postArticle": "Solar Network 上的文章"
 }
diff --git a/lib/controllers/post_write_controller.dart b/lib/controllers/post_write_controller.dart
index 4afee1e..8e84456 100644
--- a/lib/controllers/post_write_controller.dart
+++ b/lib/controllers/post_write_controller.dart
@@ -28,6 +28,8 @@ class PostWriteMedia {
   final XFile? file;
   final Uint8List? raw;
 
+  PostWriteMedia? thumbnail;
+
   PostWriteMedia(this.attachment, {this.file, this.raw}) {
     name = attachment!.name;
 
@@ -67,8 +69,7 @@ class PostWriteMedia {
     }
   }
 
-  PostWriteMedia.fromBytes(this.raw, this.name, this.type,
-      {this.attachment, this.file});
+  PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
 
   bool get isEmpty => attachment == null && file == null && raw == null;
 
@@ -102,8 +103,7 @@ class PostWriteMedia {
   }) {
     if (attachment != null) {
       final sn = context.read<SnNetworkProvider>();
-      final ImageProvider provider =
-          UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
+      final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
       if (width != null && height != null) {
         return ResizeImage(
           provider,
@@ -114,8 +114,7 @@ class PostWriteMedia {
       }
       return provider;
     } else if (file != null) {
-      final ImageProvider provider =
-          kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
+      final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
       if (width != null && height != null) {
         return ResizeImage(
           provider,
@@ -162,9 +161,10 @@ class PostWriteController extends ChangeNotifier {
   String mode = kTitleMap.keys.first;
 
   String get title => titleController.text;
+
   String get description => descriptionController.text;
-  bool get isRelatedNull =>
-      ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
+
+  bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
 
   bool isLoading = false, isBusy = false;
   double? progress;
@@ -176,6 +176,7 @@ class PostWriteController extends ChangeNotifier {
   List<int> visibleUsers = List.empty();
   List<int> invisibleUsers = List.empty();
   List<String> tags = List.empty();
+  PostWriteMedia? thumbnail;
   List<PostWriteMedia> attachments = List.empty(growable: true);
   DateTime? publishedAt, publishedUntil;
 
@@ -203,9 +204,11 @@ class PostWriteController extends ChangeNotifier {
         invisibleUsers = List.from(post.invisibleUsersList ?? []);
         visibility = post.visibility;
         tags = List.from(post.tags.map((ele) => ele.alias));
-        attachments.addAll(
-          post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [],
-        );
+        attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
+
+        if (post.preload?.thumbnail != null) {
+          thumbnail = PostWriteMedia(post.preload!.thumbnail);
+        }
 
         editingPost = post;
       }
@@ -228,6 +231,43 @@ class PostWriteController extends ChangeNotifier {
     }
   }
 
+  Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media) async {
+    final attach = context.read<SnAttachmentProvider>();
+
+    final place = await attach.chunkedUploadInitialize(
+      (await media.length())!,
+      media.name,
+      'interactive',
+      null,
+      mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
+    );
+
+    final item = await attach.chunkedUploadParts(
+      media.toFile()!,
+      place.$1,
+      place.$2,
+      onProgress: (progress) {
+        progress = progress;
+        notifyListeners();
+      },
+    );
+
+    return item;
+  }
+
+  Future<void> uploadSingleAttachment(BuildContext context, int idx) async {
+    if (isBusy) return;
+
+    final media = idx == -1 ? thumbnail! : attachments[idx];
+    isBusy = true;
+    notifyListeners();
+
+    final item = await _uploadAttachment(context, media);
+    attachments[idx] = PostWriteMedia(item);
+
+    notifyListeners();
+  }
+
   Future<void> post(BuildContext context) async {
     if (isBusy || publisher == null) return;
 
@@ -240,6 +280,11 @@ class PostWriteController extends ChangeNotifier {
 
     // Uploading attachments
     try {
+      if (thumbnail != null && thumbnail!.attachment == null) {
+        final thumb = await _uploadAttachment(context, thumbnail!);
+        thumbnail = PostWriteMedia(thumb);
+      }
+
       for (int i = 0; i < attachments.length; i++) {
         final media = attachments[i];
         if (media.attachment != null) continue; // Already uploaded, skip
@@ -250,9 +295,7 @@ class PostWriteController extends ChangeNotifier {
           media.name,
           'interactive',
           null,
-          mimetype: media.raw != null && media.type == PostWriteMediaType.image
-              ? 'image/png'
-              : null,
+          mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
         );
 
         final item = await attach.chunkedUploadParts(
@@ -261,8 +304,7 @@ class PostWriteController extends ChangeNotifier {
           place.$2,
           onProgress: (progress) {
             // Calculate overall progress for attachments
-            progress = ((i + progress) / attachments.length) *
-                kAttachmentProgressWeight;
+            progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight;
             notifyListeners();
           },
         );
@@ -292,32 +334,24 @@ class PostWriteController extends ChangeNotifier {
           'publisher': publisher!.id,
           'content': contentController.text,
           if (titleController.text.isNotEmpty) 'title': titleController.text,
-          if (descriptionController.text.isNotEmpty)
-            'description': descriptionController.text,
-          'attachments': attachments
-              .where((e) => e.attachment != null)
-              .map((e) => e.attachment!.rid)
-              .toList(),
+          if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
+          if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
+          'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
           'tags': tags.map((ele) => {'alias': ele}).toList(),
           'visibility': visibility,
           'visible_users_list': visibleUsers,
           'invisible_users_list': invisibleUsers,
-          if (publishedAt != null)
-            'published_at': publishedAt!.toUtc().toIso8601String(),
-          if (publishedUntil != null)
-            'published_until': publishedAt!.toUtc().toIso8601String(),
+          if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
+          if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
           if (replyingPost != null) 'reply_to': replyingPost!.id,
           if (repostingPost != null) 'repost_to': repostingPost!.id,
         },
         onSendProgress: (count, total) {
-          progress =
-              baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
+          progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
           notifyListeners();
         },
         onReceiveProgress: (count, total) {
-          progress = baseProgressVal +
-              (kPostingProgressWeight / 2) +
-              (count / total) * (kPostingProgressWeight / 2);
+          progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
           notifyListeners();
         },
         options: Options(
@@ -339,12 +373,31 @@ class PostWriteController extends ChangeNotifier {
   }
 
   void setAttachmentAt(int idx, PostWriteMedia item) {
-    attachments[idx] = item;
+    if (idx == -1) {
+      thumbnail = item;
+    } else {
+      attachments[idx] = item;
+    }
     notifyListeners();
   }
 
   void removeAttachmentAt(int idx) {
-    attachments.removeAt(idx);
+    if (idx == -1) {
+      thumbnail = null;
+    } else {
+      attachments.removeAt(idx);
+    }
+    notifyListeners();
+  }
+
+  void setThumbnail(int? idx) {
+    if (idx == null) {
+      attachments.add(thumbnail!);
+      thumbnail = null;
+    } else {
+      thumbnail = attachments[idx];
+      attachments.removeAt(idx);
+    }
     notifyListeners();
   }
 
@@ -383,11 +436,21 @@ class PostWriteController extends ChangeNotifier {
     notifyListeners();
   }
 
+  void setProgress(double? value) {
+    progress = value;
+    notifyListeners();
+  }
+
   void setIsBusy(bool value) {
     isBusy = value;
     notifyListeners();
   }
 
+  void setMode(String value) {
+    mode = value;
+    notifyListeners();
+  }
+
   void reset() {
     publishedAt = null;
     publishedUntil = null;
diff --git a/lib/main.dart b/lib/main.dart
index 061a4c0..06ae709 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -45,10 +45,6 @@ void main() async {
     options: DefaultFirebaseOptions.currentPlatform,
   );
 
-  if (!kReleaseMode) {
-    // debugInvertOversizedImages = true;
-  }
-
   GoRouter.optionURLReflectsImperativeAPIs = true;
   usePathUrlStrategy();
 
diff --git a/lib/screens/post/post_detail.dart b/lib/screens/post/post_detail.dart
index d890b8d..bc7ac4d 100644
--- a/lib/screens/post/post_detail.dart
+++ b/lib/screens/post/post_detail.dart
@@ -110,6 +110,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
                 data: _data!,
                 maxWidth: 640,
                 showComments: false,
+                showFullPost: true,
                 onChanged: (data) {
                   setState(() => _data = data);
                 },
diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart
index e73cf99..6474628 100644
--- a/lib/screens/post/post_editor.dart
+++ b/lib/screens/post/post_editor.dart
@@ -25,6 +25,7 @@ class PostEditorScreen extends StatefulWidget {
   final int? postEditId;
   final int? postReplyId;
   final int? postRepostId;
+
   const PostEditorScreen({
     super.key,
     required this.mode,
@@ -41,6 +42,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
   final PostWriteController _writeController = PostWriteController();
 
   bool _isFetching = false;
+
   bool get _isLoading => _isFetching || _writeController.isLoading;
 
   List<SnPublisher>? _publishers;
@@ -105,6 +107,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
     if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
       context.showErrorDialog('Unknown post type');
       Navigator.pop(context);
+    } else {
+      _writeController.setMode(widget.mode);
     }
     _fetchPublishers();
     _writeController.fetchRelatedPost(
@@ -131,21 +135,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
               textAlign: TextAlign.center,
               text: TextSpan(children: [
                 TextSpan(
-                  text: _writeController.title.isNotEmpty
-                      ? _writeController.title
-                      : 'untitled'.tr(),
-                  style: Theme.of(context)
-                      .textTheme
-                      .titleLarge!
-                      .copyWith(color: Colors.white),
+                  text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(),
+                  style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white),
                 ),
                 const TextSpan(text: '\n'),
                 TextSpan(
                   text: PostWriteController.kTitleMap[widget.mode]!.tr(),
-                  style: Theme.of(context)
-                      .textTheme
-                      .bodySmall!
-                      .copyWith(color: Colors.white),
+                  style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white),
                 ),
               ]),
             ),
@@ -181,17 +177,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                                 Expanded(
                                   child: Column(
                                     mainAxisSize: MainAxisSize.min,
-                                    crossAxisAlignment:
-                                        CrossAxisAlignment.start,
+                                    crossAxisAlignment: CrossAxisAlignment.start,
                                     children: [
-                                      Text(item.nick).textStyle(
-                                          Theme.of(context)
-                                              .textTheme
-                                              .bodyMedium!),
+                                      Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
                                       Text('@${item.name}')
-                                          .textStyle(Theme.of(context)
-                                              .textTheme
-                                              .bodySmall!)
+                                          .textStyle(Theme.of(context).textTheme.bodySmall!)
                                           .fontSize(12),
                                     ],
                                   ),
@@ -208,8 +198,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                           CircleAvatar(
                             radius: 16,
                             backgroundColor: Colors.transparent,
-                            foregroundColor:
-                                Theme.of(context).colorScheme.onSurface,
+                            foregroundColor: Theme.of(context).colorScheme.onSurface,
                             child: const Icon(Symbols.add),
                           ),
                           const Gap(8),
@@ -218,8 +207,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                               mainAxisSize: MainAxisSize.min,
                               crossAxisAlignment: CrossAxisAlignment.start,
                               children: [
-                                Text('publishersNew').tr().textStyle(
-                                    Theme.of(context).textTheme.bodyMedium!),
+                                Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
                               ],
                             ),
                           ),
@@ -230,9 +218,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                   value: _writeController.publisher,
                   onChanged: (SnPublisher? value) {
                     if (value == null) {
-                      GoRouter.of(context)
-                          .pushNamed('accountPublisherNew')
-                          .then((value) {
+                      GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
                         if (value == true) {
                           _publishers = null;
                           _fetchPublishers();
@@ -267,16 +253,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                               ),
                               child: ExpansionTile(
                                 minTileHeight: 48,
-                                leading:
-                                    const Icon(Symbols.reply).padding(left: 4),
+                                leading: const Icon(Symbols.reply).padding(left: 4),
                                 title: Text('postReplyingNotice')
                                     .fontSize(15)
-                                    .tr(args: [
-                                  '@${_writeController.replyingPost!.publisher.name}'
-                                ]),
-                                children: <Widget>[
-                                  PostItem(data: _writeController.replyingPost!)
-                                ],
+                                    .tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
+                                children: <Widget>[PostItem(data: _writeController.replyingPost!)],
                               ),
                             ),
                             const Divider(height: 1),
@@ -292,13 +273,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                               ),
                               child: ExpansionTile(
                                 minTileHeight: 48,
-                                leading: const Icon(Symbols.forward)
-                                    .padding(left: 4),
+                                leading: const Icon(Symbols.forward).padding(left: 4),
                                 title: Text('postRepostingNotice')
                                     .fontSize(15)
-                                    .tr(args: [
-                                  '@${_writeController.repostingPost!.publisher.name}'
-                                ]),
+                                    .tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
                                 children: <Widget>[
                                   PostItem(
                                     data: _writeController.repostingPost!,
@@ -319,16 +297,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                               ),
                               child: ExpansionTile(
                                 minTileHeight: 48,
-                                leading: const Icon(Symbols.edit_note)
-                                    .padding(left: 4),
+                                leading: const Icon(Symbols.edit_note).padding(left: 4),
                                 title: Text('postEditingNotice')
                                     .fontSize(15)
-                                    .tr(args: [
-                                  '@${_writeController.editingPost!.publisher.name}'
-                                ]),
-                                children: <Widget>[
-                                  PostItem(data: _writeController.editingPost!)
-                                ],
+                                    .tr(args: ['@${_writeController.editingPost!.publisher.name}']),
+                                children: <Widget>[PostItem(data: _writeController.editingPost!)],
                               ),
                             ),
                             const Divider(height: 1),
@@ -347,14 +320,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                           ),
                           border: InputBorder.none,
                         ),
-                        onTapOutside: (_) =>
-                            FocusManager.instance.primaryFocus?.unfocus(),
+                        onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
                       ),
                     ]
                         .expandIndexed(
                           (idx, ele) => [
-                            if (idx != 0 || _writeController.isRelatedNull)
-                              const Gap(8),
+                            if (idx != 0 || _writeController.isRelatedNull) const Gap(8),
                             ele,
                           ],
                         )
@@ -362,10 +333,21 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                   ),
                 ),
               ),
-              if (_writeController.attachments.isNotEmpty)
+              if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
                 PostMediaPendingList(
+                  thumbnail: _writeController.thumbnail,
                   attachments: _writeController.attachments,
                   isBusy: _writeController.isBusy,
+                  onUpload: (int idx) async {
+                    await _writeController.uploadSingleAttachment(context, idx);
+                  },
+                  onPostSetThumbnail: (int? idx) {
+                    _writeController.setThumbnail(idx);
+                  },
+                  onInsertLink: (int idx) async {
+                    _writeController.contentController.text +=
+                        '\n![](solink://attachments/${_writeController.attachments[idx].attachment!.rid})';
+                  },
                   onUpdate: (int idx, PostWriteMedia updatedMedia) async {
                     _writeController.setIsBusy(true);
                     try {
@@ -390,13 +372,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                   crossAxisAlignment: CrossAxisAlignment.start,
                   children: [
                     LoadingIndicator(isActive: _isLoading),
-                    if (_writeController.isBusy &&
-                        _writeController.progress != null)
+                    if (_writeController.isBusy && _writeController.progress != null)
                       TweenAnimationBuilder<double>(
                         tween: Tween(begin: 0, end: _writeController.progress),
                         duration: Duration(milliseconds: 300),
-                        builder: (context, value, _) =>
-                            LinearProgressIndicator(value: value, minHeight: 2),
+                        builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
                       )
                     else if (_writeController.isBusy)
                       const LinearProgressIndicator(value: null, minHeight: 2),
@@ -413,8 +393,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                                   PopupMenuButton(
                                     icon: Icon(
                                       Symbols.add_photo_alternate,
-                                      color:
-                                          Theme.of(context).colorScheme.primary,
+                                      color: Theme.of(context).colorScheme.primary,
                                     ),
                                     itemBuilder: (context) => [
                                       PopupMenuItem(
@@ -434,8 +413,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                                           children: [
                                             const Icon(Symbols.content_paste),
                                             const Gap(16),
-                                            Text('addAttachmentFromClipboard')
-                                                .tr(),
+                                            Text('addAttachmentFromClipboard').tr(),
                                           ],
                                         ),
                                         onTap: () {
@@ -450,8 +428,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
                           ),
                         ),
                         TextButton.icon(
-                          onPressed: (_writeController.isBusy ||
-                                  _writeController.publisher == null)
+                          onPressed: (_writeController.isBusy || _writeController.publisher == null)
                               ? null
                               : () {
                                   _writeController.post(context).then((_) {
diff --git a/lib/widgets/navigation/app_drawer_navigation.dart b/lib/widgets/navigation/app_drawer_navigation.dart
index 3688d29..c5eebbb 100644
--- a/lib/widgets/navigation/app_drawer_navigation.dart
+++ b/lib/widgets/navigation/app_drawer_navigation.dart
@@ -56,7 +56,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
               ],
             ).padding(
               horizontal: 32,
-              top: MediaQuery.of(context).padding.top > 16 ? 8 : 24,
+              top: MediaQuery.of(context).padding.top > 32 ? 8 : 32,
               bottom: 8,
             ),
             ...destinations.where((ele) => ele.isPinned).map((ele) {
diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart
index d095d67..933ffe8 100644
--- a/lib/widgets/post/post_item.dart
+++ b/lib/widgets/post/post_item.dart
@@ -21,21 +21,25 @@ import 'package:surface/widgets/post/post_comment_list.dart';
 import 'package:surface/widgets/post/post_meta_editor.dart';
 import 'package:surface/widgets/post/post_reaction.dart';
 import 'package:surface/widgets/post/publisher_popover.dart';
+import 'package:surface/widgets/universal_image.dart';
 
 class PostItem extends StatelessWidget {
   final SnPost data;
   final bool showReactions;
   final bool showComments;
   final bool showMenu;
+  final bool showFullPost;
   final double? maxWidth;
   final Function(SnPost data)? onChanged;
   final Function()? onDeleted;
+
   const PostItem({
     super.key,
     required this.data,
     this.showReactions = true,
     this.showComments = true,
     this.showMenu = true,
+    this.showFullPost = false,
     this.maxWidth,
     this.onChanged,
     this.onDeleted,
@@ -47,6 +51,75 @@ class PostItem extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    final sn = context.read<SnNetworkProvider>();
+
+    // Article headline preview
+    if (!showFullPost && data.type == 'article') {
+      return Container(
+        constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            _PostContentHeader(
+              data: data,
+              onDeleted: () {
+                if (onDeleted != null) {}
+              },
+            ).padding(horizontal: 12, top: 8, bottom: 4),
+            Container(
+              width: double.infinity,
+              margin: const EdgeInsets.only(bottom: 4, left: 12, right: 12),
+              decoration: BoxDecoration(
+                borderRadius: const BorderRadius.all(Radius.circular(8)),
+                border: Border.all(
+                  color: Theme.of(context).dividerColor,
+                  width: 1,
+                ),
+              ),
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  if (data.preload?.thumbnail != null)
+                    AspectRatio(
+                      aspectRatio: 16 / 9,
+                      child: ClipRRect(
+                        borderRadius: const BorderRadius.only(
+                          topLeft: Radius.circular(8),
+                          topRight: Radius.circular(8),
+                        ),
+                        child: AutoResizeUniversalImage(
+                          sn.getAttachmentUrl(data.preload!.thumbnail!.rid),
+                          fit: BoxFit.cover,
+                        ),
+                      ),
+                    ),
+                  const Gap(8),
+                  _PostHeadline(data: data).padding(horizontal: 14),
+                  const Gap(4),
+                  Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      if (data.visibility > 0) _PostVisibilityHint(data: data),
+                      _PostTruncatedHint(data: data),
+                    ],
+                  ).padding(horizontal: 12),
+                  const Gap(8),
+                ],
+              ),
+            ),
+            Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
+            if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
+            _PostBottomAction(
+              data: data,
+              showComments: showComments,
+              showReactions: showReactions,
+              onChanged: _onChanged,
+            ).padding(left: 8, right: 14),
+          ],
+        ),
+      );
+    }
+
     return Column(
       crossAxisAlignment: CrossAxisAlignment.center,
       children: [
@@ -62,11 +135,9 @@ class PostItem extends StatelessWidget {
                   if (onDeleted != null) onDeleted!();
                 },
               ).padding(horizontal: 12, vertical: 8),
-              if (data.body['title'] != null ||
-                  data.body['description'] != null)
+              if (data.body['title'] != null || data.body['description'] != null)
                 _PostHeadline(data: data).padding(horizontal: 16, bottom: 8),
-              _PostContentBody(data: data.body)
-                  .padding(horizontal: 16, bottom: 6),
+              _PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6),
               if (data.repostTo != null)
                 _PostQuoteContent(child: data.repostTo!).padding(
                   horizontal: 12,
@@ -81,8 +152,7 @@ class PostItem extends StatelessWidget {
                   horizontal: 16,
                   vertical: 4,
                 ),
-              if (data.tags.isNotEmpty)
-                _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
+              if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
             ],
           ),
         ),
@@ -116,6 +186,7 @@ class _PostBottomAction extends StatelessWidget {
   final bool showComments;
   final bool showReactions;
   final Function(SnPost data) onChanged;
+
   const _PostBottomAction({
     required this.data,
     required this.showComments,
@@ -130,9 +201,7 @@ class _PostBottomAction extends StatelessWidget {
         );
 
     final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty
-        ? data.metric.reactionList.entries
-            .reduce((a, b) => a.value > b.value ? a : b)
-            .key
+        ? data.metric.reactionList.entries.reduce((a, b) => a.value > b.value ? a : b).key
         : null;
 
     return Row(
@@ -145,8 +214,7 @@ class _PostBottomAction extends StatelessWidget {
                 InkWell(
                   child: Row(
                     children: [
-                      if (mostTypicalReaction == null ||
-                          kTemplateReactions[mostTypicalReaction] == null)
+                      if (mostTypicalReaction == null || kTemplateReactions[mostTypicalReaction] == null)
                         Icon(Symbols.add_reaction, size: 20, color: iconColor)
                       else
                         Text(
@@ -158,8 +226,7 @@ class _PostBottomAction extends StatelessWidget {
                           ),
                         ),
                       const Gap(8),
-                      if (data.totalUpvote > 0 &&
-                          data.totalUpvote >= data.totalDownvote)
+                      if (data.totalUpvote > 0 && data.totalUpvote >= data.totalDownvote)
                         Text('postReactionUpvote').plural(
                           data.totalUpvote,
                         )
@@ -178,12 +245,8 @@ class _PostBottomAction extends StatelessWidget {
                         data: data,
                         onChanged: (value, attr, delta) {
                           onChanged(data.copyWith(
-                            totalUpvote: attr == 1
-                                ? data.totalUpvote + delta
-                                : data.totalUpvote,
-                            totalDownvote: attr == 2
-                                ? data.totalDownvote + delta
-                                : data.totalDownvote,
+                            totalUpvote: attr == 1 ? data.totalUpvote + delta : data.totalUpvote,
+                            totalDownvote: attr == 2 ? data.totalDownvote + delta : data.totalDownvote,
                             metric: data.metric.copyWith(reactionList: value),
                           ));
                         },
@@ -229,6 +292,7 @@ class _PostBottomAction extends StatelessWidget {
 
 class _PostHeadline extends StatelessWidget {
   final SnPost data;
+
   const _PostHeadline({super.key, required this.data});
 
   @override
@@ -256,6 +320,7 @@ class _PostContentHeader extends StatelessWidget {
   final bool isCompact;
   final bool showMenu;
   final Function onDeleted;
+
   const _PostContentHeader({
     required this.data,
     this.isCompact = false,
@@ -438,6 +503,7 @@ class _PostContentHeader extends StatelessWidget {
 
 class _PostContentBody extends StatelessWidget {
   final dynamic data;
+
   const _PostContentBody({this.data});
 
   @override
@@ -449,6 +515,7 @@ class _PostContentBody extends StatelessWidget {
 
 class _PostQuoteContent extends StatelessWidget {
   final SnPost child;
+
   const _PostQuoteContent({super.key, required this.child});
 
   @override
@@ -479,6 +546,7 @@ class _PostQuoteContent extends StatelessWidget {
 
 class _PostTagsList extends StatelessWidget {
   final SnPost data;
+
   const _PostTagsList({super.key, required this.data});
 
   @override
@@ -505,6 +573,7 @@ class _PostTagsList extends StatelessWidget {
 
 class _PostVisibilityHint extends StatelessWidget {
   final SnPost data;
+
   const _PostVisibilityHint({super.key, required this.data});
 
   static const List<IconData> kVisibilityIcons = [
@@ -529,6 +598,7 @@ class _PostVisibilityHint extends StatelessWidget {
 
 class _PostTruncatedHint extends StatelessWidget {
   final SnPost data;
+
   const _PostTruncatedHint({super.key, required this.data});
 
   static const int kHumanReadSpeed = 238;
@@ -544,13 +614,11 @@ class _PostTruncatedHint extends StatelessWidget {
               const Gap(4),
               Text('postReadEstimate').tr(args: [
                 '${Duration(
-                  seconds: (data.body['content_length'] as num).toDouble() *
-                      60 ~/
-                      kHumanReadSpeed,
+                  seconds: (data.body['content_length'] as num).toDouble() * 60 ~/ kHumanReadSpeed,
                 ).inSeconds}s',
               ]),
             ],
-          ).padding(right: 12),
+          ).padding(right: 8),
         if (data.body['content_length'] != null)
           Row(
             children: [
diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart
index 3ff77df..1a37756 100644
--- a/lib/widgets/post/post_media_pending_list.dart
+++ b/lib/widgets/post/post_media_pending_list.dart
@@ -17,18 +17,26 @@ import 'package:surface/widgets/attachment/attachment_detail.dart';
 import 'package:surface/widgets/dialog.dart';
 
 class PostMediaPendingList extends StatelessWidget {
+  final PostWriteMedia? thumbnail;
   final List<PostWriteMedia> attachments;
   final bool isBusy;
   final Future<void> Function(int idx, PostWriteMedia updatedMedia)? onUpdate;
   final Future<void> Function(int idx)? onRemove;
+  final Future<void> Function(int idx)? onUpload;
+  final void Function(int? idx)? onPostSetThumbnail;
+  final void Function(int idx)? onInsertLink;
   final void Function(bool state)? onUpdateBusy;
 
   const PostMediaPendingList({
     super.key,
+    this.thumbnail,
     required this.attachments,
     required this.isBusy,
     this.onUpdate,
     this.onRemove,
+    this.onUpload,
+    this.onPostSetThumbnail,
+    this.onInsertLink,
     this.onUpdateBusy,
   });
 
@@ -50,10 +58,7 @@ class PostMediaPendingList extends StatelessWidget {
 
     if (result == null) return;
 
-    final rawBytes =
-        (await result.uiImage.toByteData(format: ImageByteFormat.png))!
-            .buffer
-            .asUint8List();
+    final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
 
     if (onUpdate != null) {
       final updatedMedia = PostWriteMedia.fromBytes(
@@ -66,7 +71,7 @@ class PostMediaPendingList extends StatelessWidget {
   }
 
   Future<void> _deleteAttachment(BuildContext context, int idx) async {
-    final media = attachments[idx];
+    final media = idx == -1 ? thumbnail! : attachments[idx];
     if (media.attachment == null) return;
 
     try {
@@ -82,10 +87,40 @@ class PostMediaPendingList extends StatelessWidget {
     }
   }
 
-  ContextMenu _buildContextMenu(
-      BuildContext context, int idx, PostWriteMedia media) {
+  ContextMenu _buildContextMenu(BuildContext context, int idx, PostWriteMedia media) {
     return ContextMenu(
       entries: [
+        if (media.attachment == null && onUpload != null)
+          MenuItem(
+              label: 'attachmentUpload'.tr(),
+              icon: Symbols.upload,
+              onSelected: () {
+                onUpload!(idx);
+              }),
+        if (media.attachment != null && onPostSetThumbnail != null && idx != -1)
+          MenuItem(
+            label: 'attachmentSetAsPostThumbnail'.tr(),
+            icon: Symbols.gallery_thumbnail,
+            onSelected: () {
+              onPostSetThumbnail!(idx);
+            },
+          )
+        else if (media.attachment != null && onPostSetThumbnail != null)
+          MenuItem(
+            label: 'attachmentUnsetAsPostThumbnail'.tr(),
+            icon: Symbols.cancel,
+            onSelected: () {
+              onPostSetThumbnail!(null);
+            },
+          ),
+        if (media.attachment != null && onInsertLink != null)
+          MenuItem(
+            label: 'attachmentInsertLink'.tr(),
+            icon: Symbols.add_link,
+            onSelected: () {
+              onInsertLink!(idx);
+            },
+          ),
         if (media.type == PostWriteMediaType.image && media.attachment != null)
           MenuItem(
             label: 'preview'.tr(),
@@ -135,51 +170,91 @@ class PostMediaPendingList extends StatelessWidget {
 
     return Container(
       constraints: const BoxConstraints(maxHeight: 120),
-      child: ListView.separated(
-        scrollDirection: Axis.horizontal,
-        padding: const EdgeInsets.symmetric(horizontal: 8),
-        separatorBuilder: (context, index) => const Gap(8),
-        itemCount: attachments.length,
-        itemBuilder: (context, idx) {
-          final media = attachments[idx];
-          return ContextMenuRegion(
-            contextMenu: _buildContextMenu(context, idx, media),
-            child: Container(
-              decoration: BoxDecoration(
-                border: Border.all(
-                  color: Theme.of(context).dividerColor,
-                  width: 1,
+      child: Row(
+        children: [
+          const Gap(8),
+          if (thumbnail != null)
+            ContextMenuRegion(
+              contextMenu: _buildContextMenu(context, -1, thumbnail!),
+              child: Container(
+                decoration: BoxDecoration(
+                  border: Border.all(
+                    color: Theme.of(context).dividerColor,
+                    width: 1,
+                  ),
+                  borderRadius: BorderRadius.circular(8),
                 ),
-                borderRadius: BorderRadius.circular(8),
-              ),
-              child: ClipRRect(
-                borderRadius: const BorderRadius.all(Radius.circular(8)),
-                child: AspectRatio(
-                  aspectRatio: 1,
-                  child: switch (media.type) {
-                    PostWriteMediaType.image =>
-                      LayoutBuilder(builder: (context, constraints) {
-                        return Image(
-                          image: media.getImageProvider(
-                            context,
-                            width: (constraints.maxWidth * devicePixelRatio)
-                                .round(),
-                            height: (constraints.maxHeight * devicePixelRatio)
-                                .round(),
-                          )!,
-                          fit: BoxFit.cover,
-                        );
-                      }),
-                    _ => Container(
-                        color: Theme.of(context).colorScheme.surface,
-                        child: const Icon(Symbols.docs).center(),
-                      ),
-                  },
+                child: ClipRRect(
+                  borderRadius: const BorderRadius.all(Radius.circular(8)),
+                  child: AspectRatio(
+                    aspectRatio: 1,
+                    child: switch (thumbnail!.type) {
+                      PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) {
+                          return Image(
+                            image: thumbnail!.getImageProvider(
+                              context,
+                              width: (constraints.maxWidth * devicePixelRatio).round(),
+                              height: (constraints.maxHeight * devicePixelRatio).round(),
+                            )!,
+                            fit: BoxFit.cover,
+                          );
+                        }),
+                      _ => Container(
+                          color: Theme.of(context).colorScheme.surface,
+                          child: const Icon(Symbols.docs).center(),
+                        ),
+                    },
+                  ),
                 ),
               ),
             ),
-          );
-        },
+          if (thumbnail != null) const VerticalDivider(width: 1).padding(horizontal: 8),
+          Expanded(
+            child: ListView.separated(
+              scrollDirection: Axis.horizontal,
+              padding: const EdgeInsets.only(right: 8),
+              separatorBuilder: (context, index) => const Gap(8),
+              itemCount: attachments.length,
+              itemBuilder: (context, idx) {
+                final media = attachments[idx];
+                return ContextMenuRegion(
+                  contextMenu: _buildContextMenu(context, idx, media),
+                  child: Container(
+                    decoration: BoxDecoration(
+                      border: Border.all(
+                        color: Theme.of(context).dividerColor,
+                        width: 1,
+                      ),
+                      borderRadius: BorderRadius.circular(8),
+                    ),
+                    child: ClipRRect(
+                      borderRadius: const BorderRadius.all(Radius.circular(8)),
+                      child: AspectRatio(
+                        aspectRatio: 1,
+                        child: switch (media.type) {
+                          PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) {
+                              return Image(
+                                image: media.getImageProvider(
+                                  context,
+                                  width: (constraints.maxWidth * devicePixelRatio).round(),
+                                  height: (constraints.maxHeight * devicePixelRatio).round(),
+                                )!,
+                                fit: BoxFit.cover,
+                              );
+                            }),
+                          _ => Container(
+                              color: Theme.of(context).colorScheme.surface,
+                              child: const Icon(Symbols.docs).center(),
+                            ),
+                        },
+                      ),
+                    ),
+                  ),
+                );
+              },
+            ),
+          ),
+        ],
       ),
     );
   }
diff --git a/lib/widgets/post/post_meta_editor.dart b/lib/widgets/post/post_meta_editor.dart
index 3a469f7..afdfbec 100644
--- a/lib/widgets/post/post_meta_editor.dart
+++ b/lib/widgets/post/post_meta_editor.dart
@@ -94,8 +94,8 @@ class PostMetaEditor extends StatelessWidget {
               onTapOutside: (_) =>
                   FocusManager.instance.primaryFocus?.unfocus(),
             ).padding(horizontal: 24),
-            if (controller.mode == 'article') const Gap(4),
-            if (controller.mode == 'article')
+            if (controller.mode == 'articles') const Gap(4),
+            if (controller.mode == 'articles')
               TextField(
                 controller: controller.descriptionController,
                 maxLines: null,