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<String, SnLinkMeta> _cache = {};
+
+  SnLinkPreviewProvider(BuildContext context) {
+    _sn = context.read<SnNetworkProvider>();
+  }
+
+  Future<SnLinkMeta?> 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<void> 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<String, dynamic> 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>(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<String, dynamic> 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<String, dynamic> 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<SnLinkMeta> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> _$$SnLinkMetaImplToJson(_$SnLinkMetaImpl instance) =>
+    <String, dynamic>{
+      '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<LinkPreviewWidget> createState() => _LinkPreviewWidgetState();
+}
+
+class _LinkPreviewWidgetState extends State<LinkPreviewWidget> {
+  final List<SnLinkMeta> _links = List.empty(growable: true);
+
+  Future<void> _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<SnLinkPreviewProvider>();
+
+    final List<Future<SnLinkMeta?>> 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: