Remove flutter chat #26

Manually merged
Henry-Hiles merged 108 commits from remove-flutter-chat into main 2026-05-22 15:26:28 -04:00
8 changed files with 293 additions and 275 deletions
Showing only changes of commit fee12cb94d - Show all commits

fix up url embeds

Henry Hiles 2026-05-19 10:07:15 -04:00
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs

View file

@ -26,15 +26,15 @@ class UrlPreviewController extends AsyncNotifier<OpenGraphData?> {
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final decodedValue = json.decode(response.body) as Map?; final decodedValue = json.decode(response.body);
if (decodedValue?.isNotEmpty == true) return null; if (decodedValue is! Map<String, dynamic>) return null;
final mxc = decodedValue!["og:image"]; final mxc = decodedValue["og:image"];
final image = mxc == null final image = mxc == null
? null ? null
: Uri.tryParse(mxc)?.mxcToHttps(homeserver); : Uri.tryParse(mxc)?.mxcToHttps(homeserver);
return OpenGraphData.fromJson({...decodedValue, "og:image": image}); return OpenGraphData.fromJson(decodedValue).copyWith(imageUrl: image);
} }
} }
} }

View file

@ -7,11 +7,11 @@ abstract class OpenGraphData with _$OpenGraphData {
const factory OpenGraphData({ const factory OpenGraphData({
@JsonKey(name: "og:title") required String? title, @JsonKey(name: "og:title") required String? title,
@JsonKey(name: "og:description") required String? description, @JsonKey(name: "og:description") required String? description,
@JsonKey(name: "og:image") required String? imageUrl, @JsonKey(name: "og:image") required Uri? imageUrl,
@JsonKey(name: "og:image:width") required double? width, @JsonKey(name: "og:image:width") required double? width,
@JsonKey(name: "og:image:height") required double? height, @JsonKey(name: "og:image:height") required double? height,
}) = _OpenGraphData; }) = _OpenGraphData;
factory OpenGraphData.fromJson(Map<String, Object?> json) => factory OpenGraphData.fromJson(Map<String, dynamic> json) =>
_$OpenGraphDataFromJson(json); _$OpenGraphDataFromJson(json);
} }

View file

@ -23,6 +23,7 @@ class Html extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
html, html,
buildAsync: false,
textStyle: textStyle, textStyle: textStyle,
customWidgetBuilder: (element) { customWidgetBuilder: (element) {
if (element.attributes.keys.contains("data-mx-profile-fallback")) { if (element.attributes.keys.contains("data-mx-profile-fallback")) {

View file

@ -1,8 +1,10 @@
import "package:collection/collection.dart";
import "package:cross_cache/cross_cache.dart"; import "package:cross_cache/cross_cache.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_blurhash/flutter_blurhash.dart"; import "package:flutter_blurhash/flutter_blurhash.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:linkify/linkify.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
@ -59,232 +61,239 @@ class RenderEvent extends ConsumerWidget {
children: getEventOptions!(event).toList(), children: getEventOptions!(event).toList(),
); );
return GestureDetector( final child = switch (event.content) {
onSecondaryTapUp: contextMenuCallback, MessageContent() => Row(
onLongPressStart: contextMenuCallback, crossAxisAlignment: CrossAxisAlignment.start,
child: switch (event.content) { spacing: 8,
MessageContent() => Row( children: [
crossAxisAlignment: CrossAxisAlignment.start, if (!textOnly)
spacing: 8, if (isGrouped)
children: [ SizedBox(width: 40)
isGrouped || textOnly else
? SizedBox(width: 40) MessageAvatar(event, height: 40),
: MessageAvatar(event, height: 40), Expanded(
Expanded( child: Column(
child: Column( spacing: 4,
spacing: 4, crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ if (!isGrouped && !textOnly)
if (!isGrouped && !textOnly) Row(
Row( spacing: 4,
spacing: 4, children: [
children: [ Flexible(
MessageDisplayname( child: MessageDisplayname(
event, event,
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
Flexible(child: timestamp),
],
),
ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
padding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 12,
), ),
decoration: BoxDecoration( Flexible(child: timestamp),
color: ],
ref.watch( ),
ClientStateController.provider.select( ClipRRect(
(value) => value?.userId, borderRadius: textOnly
), ? BorderRadius.zero
) == : BorderRadius.all(Radius.circular(8)),
event.sender child: Container(
? (event.eventId.startsWith("~") padding: textOnly
? colorScheme.onPrimary ? EdgeInsets.zero
: colorScheme.primaryContainer) : EdgeInsets.symmetric(vertical: 8, horizontal: 12),
: colorScheme.surfaceContainer, decoration: textOnly
), ? null
child: Column( : BoxDecoration(
crossAxisAlignment: CrossAxisAlignment.start, color:
children: [ ref.watch(
// Quoted( // TODO: Show replies
// EventText(replyEvent textOnly: true, maxLines: 1,)
// ),
switch (event.content) {
Content(:final parseError?) => SelectableText(
"An error occurred while parsing this message:\n$parseError",
style: errorStyle,
),
TextMessageContent(
:final body,
:final formattedBody,
:final format,
) ||
ImageMessageContent(
:final body,
:final formattedBody,
:final format,
) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
format == "org.matrix.custom.html" && !textOnly
? Html(
textStyle:
event.localContent?.bigEmoji == true
? TextStyle(fontSize: 32)
: null,
formattedBody!.replaceAllMapped(
RegExp(
"(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
caseSensitive: false,
dotAll: true,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
),
)
: Linkify(
text: body,
maxLines: maxLines,
options: LinkifyOptions(
humanize: false,
),
onOpen: (link) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(link.url)),
linkStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.primary,
),
),
if (event.content case ImageMessageContent(
:final url,
:final info,
))
switch (url?.mxcToHttps(
ref.watch(
ClientStateController.provider.select( ClientStateController.provider.select(
(value) => value!.homeserverUrl!, (value) => value?.userId,
),
) ==
event.sender
? (event.eventId.startsWith("~")
? colorScheme.onPrimary
: colorScheme.primaryContainer)
: colorScheme.surfaceContainer,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quoted( // TODO: Show replies
// EventText(replyEvent textOnly: true, maxLines: 1,)
// ),
switch (event.content) {
Content(:final parseError?) => SelectableText(
"An error occurred while parsing this message:\n$parseError",
style: errorStyle,
),
TextMessageContent(
:final body,
:final formattedBody,
:final format,
) ||
NoticeMessageContent(
:final body,
:final formattedBody,
:final format,
) ||
ImageMessageContent(
:final body,
:final formattedBody,
:final format,
) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
format == "org.matrix.custom.html" && !textOnly
? Html(
textStyle:
event.localContent?.bigEmoji == true
? TextStyle(fontSize: 32)
: null,
formattedBody!.replaceAllMapped(
RegExp(
"(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
caseSensitive: false,
dotAll: true,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
),
)
: Linkify(
text: body,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
options: LinkifyOptions(humanize: false),
onOpen: (link) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(link.url)),
linkStyle: TextStyle(
color: Theme.of(
context,
).colorScheme.primary,
), ),
), ),
)) { if (event.content case ImageMessageContent(
final url? => ExpandableImage( :final url,
url.toString(), :final info,
child: ClipRRect( ))
borderRadius: BorderRadius.all( switch (url?.mxcToHttps(
Radius.circular(8), ref.watch(
), ClientStateController.provider.select(
child: Image( (value) => value!.homeserverUrl!,
image: CachedNetworkImage( ),
url.toString(), ),
ref.watch( )) {
CrossCacheController.provider, final url? => ExpandableImage(
), url.toString(),
headers: ref.headers, child: ClipRRect(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
child: Image(
image: CachedNetworkImage(
url.toString(),
ref.watch(
CrossCacheController.provider,
), ),
width: info?.width, headers: ref.headers,
height: info?.height, ),
loadingBuilder: width: info?.width,
(_, child, loadingProgress) => height: info?.height,
loadingProgress == null loadingBuilder:
? child (_, child, loadingProgress) =>
: switch (info?.blurHash) { loadingProgress == null
final blurHash? => ? child
SizedBox( : switch (info?.blurHash) {
width: final blurHash? => SizedBox(
info?.width ?? width:
info?.height ?? info?.width ??
200, info?.height ??
height: 200,
info?.height ?? height:
info?.width ?? info?.height ??
200, info?.width ??
child: BlurHash( 200,
hash: blurHash, child: BlurHash(
), hash: blurHash,
),
_ => Loading(),
},
errorBuilder:
(context, error, stackTrace) =>
Center(
child: Text(
"Image Failed to Load",
style: TextStyle(
color: Theme.of(
context,
).colorScheme.error,
), ),
), ),
_ => Loading(),
},
errorBuilder:
(context, error, stackTrace) =>
Center(
child: Text(
"Image Failed to Load",
style: TextStyle(
color: Theme.of(
context,
).colorScheme.error,
),
), ),
), ),
), ),
), ),
_ => Text(
"Nexus currently cannot handle encrypted media",
style: errorStyle,
),
},
if (event.lastEditRowId != null)
Text(
"(edited)",
style: theme.textTheme.labelSmall,
), ),
if (RegExp( _ => Text(
r'''https?://[^\s"'<>]+''', "Nexus currently cannot handle encrypted media",
).allMatches(body).firstOrNull?.group(0) style: errorStyle,
case final link?) ),
LinkPreview(link), },
], if (event.lastEditRowId != null && !textOnly)
), Text(
_ => "(edited)",
textOnly style: theme.textTheme.labelSmall,
? Text( ),
"Unknown message type", if (linkify(body).firstWhereOrNull(
style: errorStyle, (element) => element is UrlElement,
) )
: SizedBox.shrink(), case final UrlElement link?)
}, LinkPreview(link.url),
], ],
), ),
_ => Text("Unknown message type", style: errorStyle),
},
],
), ),
), ),
], ),
), ],
), ),
], ),
), ],
AvatarContent() => Row( ),
spacing: 4, AvatarContent() => Row(
children: [ spacing: 4,
SizedBox(width: 4), children: [
Icon(Icons.numbers), SizedBox(width: 4),
MessageDisplayname( Icon(Icons.numbers),
event, MessageDisplayname(
style: TextStyle( event,
color: theme.colorScheme.primary, style: TextStyle(
fontWeight: FontWeight.bold, color: theme.colorScheme.primary,
), fontWeight: FontWeight.bold,
), ),
Text("changed the room avatar"), ),
], Text("changed the room avatar"),
), ],
_ => ),
textOnly _ => null,
? Text("Unknown event type", style: errorStyle) };
: SizedBox.shrink(),
}, return GestureDetector(
onSecondaryTapUp: contextMenuCallback,
onLongPressStart: contextMenuCallback,
child: child == null
? textOnly
? Text("Unknown event type", style: errorStyle)
: SizedBox.shrink()
: Padding(padding: EdgeInsets.symmetric(vertical: 8), child: child),
); );
} }
} }

View file

@ -325,28 +325,25 @@ class RoomChat extends HookConsumerWidget {
SuperSliverList.builder( SuperSliverList.builder(
listController: listController.value, listController: listController.value,
itemCount: value.length, itemCount: value.length,
itemBuilder: (_, index) => Padding( itemBuilder: (_, index) => EventWrapper(
padding: EdgeInsets.symmetric(vertical: 8), value[index],
child: EventWrapper( RenderEvent(
value[index], value[index],
RenderEvent( onTapReply: () =>
value[index], listController.value.animateToItem(
onTapReply: () => index: index,
listController.value.animateToItem( scrollController: scrollController,
index: index, alignment: 0.5,
scrollController: scrollController, duration: (_) =>
alignment: 0.5, Duration(milliseconds: 250),
duration: (_) => curve: (_) => Curves.easeInOut,
Duration(milliseconds: 250), ),
curve: (_) => Curves.easeInOut, getEventOptions: getEventOptions,
), // TODO: Reimplement grouping
getEventOptions: getEventOptions, isGrouped: false,
// TODO: Reimplement grouping
isGrouped: false,
),
// TODO: Reimplement flashing
isFlashing: false,
), ),
// TODO: Reimplement flashing
isFlashing: false,
), ),
), ),
], ],

View file

@ -17,44 +17,48 @@ class LinkPreview extends ConsumerWidget {
.betterWhen( .betterWhen(
data: (preview) => preview == null data: (preview) => preview == null
? SizedBox.shrink() ? SizedBox.shrink()
: InkWell( : ConstrainedBox(
onTap: () => constraints: BoxConstraints.loose(Size.fromWidth(400)),
ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(link)), child: InkWell(
child: Card( onTap: () => ref
child: Column( .watch(LaunchHelper.provider)
children: [ .launchUrl(Uri.parse(link)),
if (preview.title != null) child: Card(
Text( child: Padding(
preview.title!, padding: EdgeInsetsGeometry.all(8),
style: Theme.of(context).textTheme.labelLarge, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
if (preview.description != null) spacing: 4,
Text(preview.description!), children: [
if (preview.imageUrl != null) if (preview.title != null)
Image( Text(
errorBuilder: (_, _, _) => SizedBox.shrink(), preview.title!,
width: preview.width, style: Theme.of(context).textTheme.titleLarge,
height: preview.height, ),
image: CachedNetworkImage( if (preview.description != null) ...[
preview.imageUrl!, Text(preview.description!),
ref.watch(CrossCacheController.provider), SizedBox(height: 4),
headers: ref.headers, ],
), if (preview.imageUrl != null)
fit: BoxFit.cover, ClipRRect(
), borderRadius: BorderRadius.all(
], Radius.circular(8),
),
child: Image(
errorBuilder: (_, _, _) => SizedBox.shrink(),
width: preview.width,
image: CachedNetworkImage(
preview.imageUrl.toString(),
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),
fit: BoxFit.fitWidth,
),
),
],
),
),
), ),
// text: link,
// backgroundColor: isSentByMe
// ? colorScheme.inversePrimary
// : colorScheme.surfaceContainerLow,
// outsidePadding: EdgeInsets.only(top: 4),
// insidePadding: EdgeInsets.symmetric(
// vertical: 8,
// horizontal: 16,
// ),
// linkPreviewData: preview,
// onLinkPreviewDataFetched: (_) => null,
), ),
), ),
); );

View file

@ -720,12 +720,13 @@ packages:
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
linkify: linkify:
dependency: transitive dependency: "direct overridden"
description: description:
name: linkify path: "."
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" ref: "fix/consecutive-periods-loose-url"
url: "https://pub.dev" resolved-ref: e990021f30b8535b462d41a39f37019045ae55f4
source: hosted url: "https://github.com/appelladev/linkify"
source: git
version: "5.0.0" version: "5.0.0"
lints: lints:
dependency: transitive dependency: transitive

View file

@ -11,6 +11,12 @@ flutter:
environment: environment:
sdk: "3.11.4" sdk: "3.11.4"
dependency_overrides:
linkify:
git:
url: https://github.com/appelladev/linkify
ref: fix/consecutive-periods-loose-url
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter