Add server-generated URL preview support

This commit is contained in:
Henry Hiles 2026-04-03 19:39:39 -04:00
commit f4624c2866
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 128 additions and 49 deletions

View file

@ -67,31 +67,32 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Plain text - [x] Plain text
- [x] Per message profiles - [x] Per message profiles
- [x] HTML - [x] HTML
- [x] Replies - [x] URL Previews
- [x] Viewing - [x] Replies
- [ ] Jump to original message - [x] Viewing
- [x] In loaded timeline - [ ] Jump to original message
- [ ] Out of loaded timeline - [x] In loaded timeline
- [x] Edits - [ ] Out of loaded timeline
- [x] Attachments - [x] Edits
- [x] Unencrypted - [x] Attachments
- [ ] Encrypted - [x] Unencrypted
- [x] Blurhashing - [ ] Encrypted
- [ ] Downloading attachments - [x] Blurhashing
- [x] Opening attachments in their own view - [ ] Downloading attachments
- [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 - [x] Opening attachments in their own view
- [x] Mentions - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1
- [x] Users - [x] Mentions
- [x] Rooms - [x] Users
- [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) - [x] Rooms
- [x] Matrix URIs - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest)
- [x] Matrix.to links - [x] Matrix URIs
- [ ] Do some fancy fetching to get nice names - [x] Matrix.to links
- [ ] Make clickable - [ ] Do some fancy fetching to get nice names
- [x] Custom emojis/stickers - [ ] Make clickable
- [x] History loading - [x] Custom emojis/stickers
- [x] Backwards - [x] History loading
- [ ] Forwards - [x] Backwards
- [ ] Forwards
- [x] Editing - [x] Editing
- [x] Deleting - [x] Deleting
- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl - [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl
@ -116,7 +117,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] Devices - [ ] Devices
- [ ] Viewing devices - [ ] Viewing devices
- [ ] Verifying devices - [ ] Verifying devices
- [ ] URL preview: Server / Client / None - [ ] URL preview: Server / Sending Client (Beeper spec) / None
- [ ] Account changes - [ ] Account changes
- [ ] Display name - [ ] Display name
- [ ] Profile picture - [ ] Profile picture

View file

@ -0,0 +1,63 @@
import "dart:convert";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:http/http.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/header_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
class UrlPreviewController extends AsyncNotifier<LinkPreviewData?> {
final TextMessage message;
UrlPreviewController(this.message);
@override
Future<LinkPreviewData?> build() async {
final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl;
final link = RegExp(
r'''https?://[^\s"'<>]+''',
).allMatches(message.text).firstOrNull?.group(0);
if (homeserver != null && link != null) {
{
final response = await get(
Uri.parse(homeserver)
.resolve("/_matrix/client/v1/media/preview_url")
.replace(queryParameters: {"url": link}),
headers: await ref.watch(HeaderController.provider.future),
);
if (response.statusCode == 200) {
final decodedValue = json.decode(response.body);
final mxc = decodedValue["og:image"];
final image = mxc == null
? null
: Uri.tryParse(mxc)?.mxcToHttps(homeserver);
return LinkPreviewData(
link: link,
title: decodedValue["og:title"],
description: decodedValue["og:description"],
image: image == null
? null
: ImagePreviewData(
url: image.toString(),
width:
(decodedValue["og:image:width"] as int?)?.toDouble() ??
0,
height:
(decodedValue["og:image:height"] as int?)?.toDouble() ??
0,
),
);
}
}
}
return null;
}
static final provider = AsyncNotifierProvider.autoDispose
.family<UrlPreviewController, LinkPreviewData?, TextMessage>(
UrlPreviewController.new,
);
}

View file

@ -1,11 +1,17 @@
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_link_previewer/flutter_link_previewer.dart"; import "package:flutter_link_previewer/flutter_link_previewer.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/controllers/url_preview_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.dart"; import "package:nexus/widgets/chat_page/reply_widget.dart";
class TextMessageWrapper extends StatelessWidget { class TextMessageWrapper extends ConsumerWidget {
final Message message; final Message message;
final String? content; final String? content;
final MessageGroupStatus? groupStatus; final MessageGroupStatus? groupStatus;
@ -27,7 +33,7 @@ class TextMessageWrapper extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final textMessage = message is TextMessage ? message as TextMessage : null; final textMessage = message is TextMessage ? message as TextMessage : null;
@ -80,27 +86,35 @@ class TextMessageWrapper extends StatelessWidget {
if (textMessage?.editedAt != null) if (textMessage?.editedAt != null)
Text("(edited)", style: theme.textTheme.labelSmall), Text("(edited)", style: theme.textTheme.labelSmall),
if (textMessage != null) if (textMessage != null)
LinkPreview( ref
text: textMessage.text, .watch(UrlPreviewController.provider(textMessage))
backgroundColor: isSentByMe .betterWhen(
? colorScheme.inversePrimary loading: SizedBox.shrink,
: colorScheme.surfaceContainerLow, data: (preview) => preview == null
outsidePadding: EdgeInsets.only(top: 4), ? SizedBox.shrink()
insidePadding: EdgeInsets.symmetric( : LinkPreview(
vertical: 8, imageBuilder: (url) => Image(
horizontal: 16, image: CachedNetworkImage(
), url,
linkPreviewData: message.metadata?["linkPreviewData"], ref.watch(CrossCacheController.provider),
onLinkPreviewDataFetched: (linkPreviewData) => updateMessage( headers: ref.headers,
message, ),
message.copyWith( fit: BoxFit.cover,
metadata: { errorBuilder: (_, _, _) => SizedBox.shrink(),
...(message.metadata ?? {}), ),
"linkPreviewData": linkPreviewData, text: textMessage.text,
}, backgroundColor: isSentByMe
? colorScheme.inversePrimary
: colorScheme.surfaceContainerLow,
outsidePadding: EdgeInsets.only(top: 4),
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: preview,
onLinkPreviewDataFetched: (_) => null,
),
), ),
),
),
if (extra != null) extra!, if (extra != null) extra!,
], ],
), ),

View file

@ -655,7 +655,7 @@ packages:
source: hosted source: hosted
version: "0.15.6" version: "0.15.6"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View file

@ -56,6 +56,7 @@ dependencies:
code_assets: ^1.0.0 code_assets: ^1.0.0
ffigen: ^20.1.1 ffigen: ^20.1.1
timeago: ^3.7.1 timeago: ^3.7.1
http: ^1.6.0
dev_dependencies: dev_dependencies:
build_runner: ^2.4.11 build_runner: ^2.4.11