general fixups, plus adding colors for names

This commit is contained in:
Henry Hiles 2026-05-20 16:06:17 -04:00
commit cff580dee2
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
14 changed files with 196 additions and 122 deletions

View file

@ -1,6 +1,7 @@
import "dart:async"; import "dart:async";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/user_controller.dart"; import "package:nexus/controllers/user_controller.dart";
import "package:nexus/models/configs/user_config.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
@ -11,7 +12,9 @@ class AuthorController extends AsyncNotifier<MembershipContent> {
@override @override
Future<MembershipContent> build() async { Future<MembershipContent> build() async {
final member = await ref.watch( final member = await ref.watch(
UserController.provider(event.sender).future, UserController.provider(
UserConfig(roomId: event.roomId, userId: event.sender),
).future,
); );
return MembershipContent( return MembershipContent(

View file

@ -4,25 +4,44 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/controllers/profile_controller.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/configs/user_config.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_status.dart"; import "package:nexus/models/membership_status.dart";
class UserController extends AsyncNotifier<MembershipContent> { class UserController extends AsyncNotifier<MembershipContent> {
final String userId; final UserConfig config;
UserController(this.userId); UserController(this.config);
@override @override
Future<MembershipContent> build() async { Future<MembershipContent> build() async {
final profile = await ref.watch(ProfileController.provider(userId).future); final member = config.roomId == null
? null
: await ref.watch(
MembersController.provider(config.roomId!).selectAsync(
(value) => value.firstWhereOrNull(
(membership) => membership.stateKey == config.userId,
),
),
);
if (member?.content case final MembershipContent content) {
return content;
}
final profile = await ref.watch(
ProfileController.provider(config.userId).future,
);
return MembershipContent( return MembershipContent(
status: MembershipStatus.leave, status: MembershipStatus.leave,
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
displayName: profile.displayName ?? userId.localpart, displayName: profile.displayName ?? config.userId.localpart,
); );
} }
static final provider = static final provider =
AsyncNotifierProvider.family<UserController, MembershipContent, String>( AsyncNotifierProvider.family<
UserController.new, UserController,
); MembershipContent,
UserConfig
>(UserController.new);
} }

View file

@ -67,8 +67,8 @@ void main() async {
await windowManager.setMinimumSize(Size.square(500)); await windowManager.setMinimumSize(Size.square(500));
} }
FlutterError.onError = (FlutterErrorDetails details) => // FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack); // showError(details.exception.toString(), details.stack);
runApp( runApp(
ProviderScope( ProviderScope(

View file

@ -0,0 +1,12 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "user_config.freezed.dart";
part "user_config.g.dart";
@freezed
abstract class UserConfig with _$UserConfig {
const factory UserConfig({required String? roomId, required String userId}) =
_UserConfig;
factory UserConfig.fromJson(Map<String, Object?> json) =>
_$UserConfigFromJson(json);
}

View file

@ -2,9 +2,7 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/event_preview.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
class RelationPreview extends ConsumerWidget { class RelationPreview extends ConsumerWidget {
final Event? relatedEvent; final Event? relatedEvent;
@ -29,7 +27,7 @@ class RelationPreview extends ConsumerWidget {
return Container( return Container(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
spacing: 8, spacing: 8,
children: [ children: [
@ -39,30 +37,10 @@ class RelationPreview extends ConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
MessageAvatar(relatedEvent!),
Expanded( Expanded(
child: Row( child: Padding(
spacing: 8, padding: EdgeInsets.symmetric(vertical: 8),
children: [ child: EventPreview(relatedEvent!),
Flexible(
child: MessageDisplayname(
relatedEvent!,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: IgnorePointer(
child: EventRenderer(
relatedEvent!,
textOnly: true,
maxLines: 1,
),
),
),
],
), ),
), ),

View file

@ -0,0 +1,36 @@
import "package:flutter/material.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/event.dart";
class EventPreview extends StatelessWidget {
final Event event;
const EventPreview(this.event, {super.key});
@override
Widget build(BuildContext context) => IgnorePointer(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
spacing: 12,
children: [
if (event.content is MessageContent) MessageAvatar(event),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4,
runSpacing: 2,
children: [
if (event.content is MessageContent) MessageDisplayname(event),
EventRenderer(event, textOnly: true, maxLines: 1),
],
),
),
],
),
),
);
}

View file

@ -17,8 +17,9 @@ import "package:nexus/widgets/html/quoted.dart";
class Html extends ConsumerWidget { class Html extends ConsumerWidget {
final String html; final String html;
final String? roomId;
final TextStyle? textStyle; final TextStyle? textStyle;
const Html(this.html, {this.textStyle, super.key}); const Html(this.html, {this.roomId, this.textStyle, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
@ -59,13 +60,15 @@ class Html extends ConsumerWidget {
) )
: null, : null,
"blockquote" => Quoted(Html(element.innerHtml)), "blockquote" => Quoted(
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
),
"a" => "a" =>
element.attributes["href"]?.mention == null element.attributes["href"]?.mention == null
? null ? null
: InlineCustomWidget( : InlineCustomWidget(
child: MentionChip(element.attributes["href"]!), child: MentionChip(element.attributes["href"]!, roomId),
), ),
"img" => "img" =>

View file

@ -3,17 +3,23 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/user_controller.dart"; import "package:nexus/controllers/user_controller.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/configs/user_config.dart";
class MentionChip extends ConsumerWidget { class MentionChip extends ConsumerWidget {
final String? roomId;
final String content; final String content;
const MentionChip(this.content, {super.key}); const MentionChip(this.content, this.roomId, {super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final mention = content.mention; final mention = content.mention;
final membership = mention?.startsWith("@") == true final membership = mention?.startsWith("@") == true
? ref ? ref
.watch(UserController.provider(mention!)) .watch(
UserController.provider(
UserConfig(roomId: roomId, userId: mention!),
),
)
.whenOrNull(data: (data) => data) .whenOrNull(data: (data) => data)
: null; : null;

View file

@ -10,7 +10,7 @@ import "package:nexus/widgets/avatar_or_hash.dart";
class MessageAvatar extends ConsumerWidget { class MessageAvatar extends ConsumerWidget {
final Event event; final Event event;
final double height; final double height;
const MessageAvatar(this.event, {this.height = 16, super.key}); const MessageAvatar(this.event, {this.height = 24, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => ref Widget build(BuildContext context, WidgetRef ref) => ref

View file

@ -1,3 +1,4 @@
import "package:color_hash/color_hash.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author_controller.dart"; import "package:nexus/controllers/author_controller.dart";
@ -31,7 +32,17 @@ class MessageDisplayname extends ConsumerWidget {
: null, : null,
child: Text( child: Text(
"${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}", "${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}",
style: style, style:
style ??
TextStyle(
color: ColorHash(
event.sender,
lightness: .7,
saturation: .7,
).color,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),

View file

@ -1,14 +1,16 @@
import "package:color_hash/color_hash.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_by_status_controller.dart"; import "package:nexus/controllers/members_by_status_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/configs/members_by_status_config.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_status.dart"; import "package:nexus/models/membership_status.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
class MemberList extends HookConsumerWidget { class MemberList extends HookConsumerWidget {
final String roomId; final String roomId;
@ -63,10 +65,14 @@ class MemberList extends HookConsumerWidget {
), ),
], ],
), ),
membersProvider.betterWhen( switch (membersProvider) {
data: (members) => Expanded( AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
AsyncData(:final value) || AsyncLoading(:final value?) => Expanded(
child: ListView( child: ListView(
children: members children: value
.map( .map(
(member) => switch (member.content) { (member) => switch (member.content) {
MembershipContent( MembershipContent(
@ -87,6 +93,14 @@ class MemberList extends HookConsumerWidget {
title: Text( title: Text(
displayName ?? member.stateKey!.localpart, displayName ?? member.stateKey!.localpart,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(
color: ColorHash(
member.stateKey!,
lightness: .7,
saturation: .8,
).color,
fontWeight: FontWeight.bold,
),
), ),
subtitle: Text( subtitle: Text(
member.stateKey!, member.stateKey!,
@ -100,7 +114,8 @@ class MemberList extends HookConsumerWidget {
.toList(), .toList(),
), ),
), ),
), AsyncLoading _ => Loading(),
},
], ],
), ),
); );

View file

@ -19,6 +19,7 @@ import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/widgets/event_preview.dart";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/html/html.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart";
@ -59,6 +60,8 @@ class EventRenderer extends ConsumerWidget {
message: event.timestamp.toString(), message: event.timestamp.toString(),
child: Text( child: Text(
format(event.timestamp), format(event.timestamp),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey),
), ),
); );
@ -83,6 +86,7 @@ class EventRenderer extends ConsumerWidget {
), ),
MessageContent() || EncryptedContent() => Row( MessageContent() || EncryptedContent() => Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
if (!textOnly) if (!textOnly)
@ -90,7 +94,7 @@ class EventRenderer extends ConsumerWidget {
SizedBox(width: 40) SizedBox(width: 40)
else else
MessageAvatar(event, height: 40), MessageAvatar(event, height: 40),
Expanded( Flexible(
child: Column( child: Column(
spacing: 4, spacing: 4,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -99,16 +103,14 @@ class EventRenderer extends ConsumerWidget {
Row( Row(
spacing: 4, spacing: 4,
children: [ children: [
Flexible( Flexible(child: MessageDisplayname(event)),
child: MessageDisplayname( Flexible(flex: 0, child: timestamp),
event,
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Flexible(child: timestamp),
], ],
), ),
Card( Card(
margin: textOnly
? EdgeInsets.zero
: EdgeInsets.only(bottom: 4),
color: textOnly color: textOnly
? Colors.transparent ? Colors.transparent
: ref.watch( : ref.watch(
@ -152,32 +154,7 @@ class EventRenderer extends ConsumerWidget {
AsyncData(:final value?) || AsyncData(:final value?) ||
AsyncLoading( AsyncLoading(
:final value?, :final value?,
) => IgnorePointer( ) => EventPreview(value),
child: Row(
spacing: 8,
children: [
MessageAvatar(value, height: 24),
Flexible(
child: MessageDisplayname(
value,
style: TextStyle(
color: theme
.colorScheme
.primary,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: EventRenderer(
value,
textOnly: true,
maxLines: 1,
),
),
],
),
),
AsyncError _ => Text( AsyncError _ => Text(
"An error occurred while fetching the reply", "An error occurred while fetching the reply",
style: errorStyle, style: errorStyle,
@ -233,6 +210,7 @@ class EventRenderer extends ConsumerWidget {
children: [ children: [
format == MessageFormat.html && !textOnly format == MessageFormat.html && !textOnly
? Html( ? Html(
roomId: event.roomId,
textStyle: textStyle, textStyle: textStyle,
formattedBody!.replaceAllMapped( formattedBody!.replaceAllMapped(
RegExp( RegExp(
@ -291,7 +269,7 @@ class EventRenderer extends ConsumerWidget {
)) { )) {
final url? => ConstrainedBox( final url? => ConstrainedBox(
constraints: BoxConstraints.loose( constraints: BoxConstraints.loose(
Size.fromWidth(500), Size.square(500),
), ),
child: switch (event.content) { child: switch (event.content) {
VideoMessageContent( VideoMessageContent(
@ -425,14 +403,8 @@ class EventRenderer extends ConsumerWidget {
padding: EdgeInsets.symmetric(horizontal: 4), padding: EdgeInsets.symmetric(horizontal: 4),
child: Icon(Icons.numbers), child: Icon(Icons.numbers),
), ),
MessageDisplayname( Flexible(child: MessageDisplayname(event)),
event, Expanded(child: Text("changed the room avatar")),
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
Text("changed the room avatar"),
], ],
), ),
_ => null, _ => null,

View file

@ -19,43 +19,54 @@ class MembershipRenderer extends StatelessWidget {
return switch (event.content) { return switch (event.content) {
MembershipContent content => Row( MembershipContent content => Row(
spacing: 4, spacing: 8,
children: [ children: [
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 4), padding: EdgeInsets.symmetric(horizontal: 4),
child: Icon(Icons.people), child: Icon(Icons.people),
), ),
InkWell( Expanded(
onTapUp: (details) => context.showUserPopover( child: Wrap(
content, spacing: 4,
event.stateKey!, children: [
globalPosition: details.globalPosition, InkWell(
), onTapUp: (details) => context.showUserPopover(
child: Text( content,
content.displayName ?? event.stateKey!.localpart, event.stateKey!,
style: TextStyle( globalPosition: details.globalPosition,
color: Theme.of(context).colorScheme.primary, ),
fontWeight: FontWeight.bold, child: Text(
), overflow: TextOverflow.ellipsis,
content.displayName ?? event.stateKey!.localpart,
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
Text(
overflow: TextOverflow.ellipsis,
maxLines: 1,
"${switch (content.status) {
MembershipStatus.invite => "was invited to",
MembershipStatus.join => "joined",
MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
MembershipStatus.ban => "was banned from",
MembershipStatus.knock => "asked to join",
}} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}",
),
if (event.sender != event.stateKey)
MessageDisplayname(
event,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
), ),
), ),
Text(
"${switch (content.status) {
MembershipStatus.invite => "was invited to",
MembershipStatus.join => "joined",
MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
MembershipStatus.ban => "was banned from",
MembershipStatus.knock => "asked to join",
}} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}",
),
if (event.sender != event.stateKey)
MessageDisplayname(
event,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
], ],
), ),
_ => SizedBox.shrink(), _ => SizedBox.shrink(),

View file

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
boxy:
dependency: "direct main"
description:
name: boxy
sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
build: build:
dependency: transitive dependency: transitive
description: description: