Make displayname colors based off of power level

Fixes #128
This commit is contained in:
Henry Hiles 2026-10-23 01:10:01 -04:00
commit c0f4c053b0
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 94 additions and 74 deletions

View file

@ -0,0 +1,13 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/helpers/extensions/string_to_color.dart";
extension DisplaynameColor on WidgetRef {
/// The color of [userId]'s name in [roomId], hashed from their power level,
/// so everyone with the same level shares one.
Color displaynameColor(String roomId, String userId) => watch(
RoomPowerLevelsController.provider(roomId)
.select((levels) => levels.levelOf(userId)),
).toString().colorHash;
}

View file

@ -3,7 +3,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author.dart"; import "package:nexus/controllers/author.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/helpers/extensions/string_to_color.dart"; import "package:nexus/helpers/extensions/displayname_color.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
@ -15,43 +15,44 @@ class const MessageDisplayname(
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( Widget build(BuildContext context, WidgetRef ref) {
AuthorController.provider(event), final color = ref.displaynameColor(event.roomId, event.sender);
)) {
AsyncData(:final MembershipContent? value) || return switch (ref.watch(AuthorController.provider(event))) {
AsyncLoading(:final value) || AsyncData(:final MembershipContent? value) ||
AsyncError(:final value) => InkWell( AsyncLoading(:final value) ||
onTap: clickable AsyncError(:final value) => InkWell(
? () => context.showUserPopover(event.sender, roomId: event.roomId) onTap: clickable
: null, ? () => context.showUserPopover(event.sender, roomId: event.roomId)
child: switch ([ : null,
Text( child: switch ([
value?.displayName ?? event.sender.localpart,
style:
style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
maxLines: 1,
overflow: .ellipsis,
),
if (event.pmp != null)
Text( Text(
"(via ${event.sender})", value?.displayName ?? event.sender.localpart,
style: Theme.of(context).textTheme.labelSmall style: style ?? .new(color: color, fontWeight: .bold),
?.copyWith(color: event.sender.colorHash, fontWeight: .bold),
maxLines: 1, maxLines: 1,
overflow: .ellipsis, overflow: .ellipsis,
), ),
]) { if (event.pmp != null)
final children when shouldWrap => Wrap( Text(
spacing: 4, "(via ${event.sender})",
crossAxisAlignment: .center, style: Theme.of(context).textTheme.labelSmall
children: children, ?.copyWith(color: color, fontWeight: .bold),
), maxLines: 1,
final children => Row( overflow: .ellipsis,
spacing: 4, ),
mainAxisSize: .min, ]) {
children: [for (final child in children) Flexible(child: child)], final children when shouldWrap => Wrap(
), spacing: 4,
}, crossAxisAlignment: .center,
), children: children,
}; ),
final children => Row(
spacing: 4,
mainAxisSize: .min,
children: [for (final child in children) Flexible(child: child)],
),
},
),
};
}
} }

View file

@ -8,7 +8,7 @@ import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/members_grouped.dart"; import "package:nexus/controllers/members_grouped.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/helpers/extensions/string_to_color.dart"; import "package:nexus/helpers/extensions/displayname_color.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";
@ -108,42 +108,44 @@ class const MemberList(final String roomId, {super.key})
:final avatarUrl, :final avatarUrl,
:final displayName, :final displayName,
) => ) =>
Builder( Consumer(
builder: (context) => ListTile( builder: (context, ref, _) =>
onTap: () => ListTile(
context.showUserPopover( onTap: () =>
context.showUserPopover(
members[index].stateKey!,
roomId: roomId,
),
title: Text(
displayName ??
members[index]
.stateKey!
.localpart,
overflow: .ellipsis,
style: .new(
color: ref.displaynameColor(
roomId,
members[index].stateKey!,
),
fontWeight: .bold,
),
),
subtitle: Text(
members[index].stateKey!, members[index].stateKey!,
roomId: roomId, overflow: .ellipsis,
),
leading: Transform.translate(
offset: .new(0, 2),
child: AvatarOrHash(
avatarUrl,
dimension: 40,
displayName ??
members[index]
.stateKey!
.localpart,
),
), ),
title: Text(
displayName ??
members[index]
.stateKey!
.localpart,
overflow: .ellipsis,
style: .new(
color: members[index]
.stateKey!
.colorHash,
fontWeight: .bold,
), ),
),
subtitle: Text(
members[index].stateKey!,
overflow: .ellipsis,
),
leading: Transform.translate(
offset: .new(0, 2),
child: AvatarOrHash(
avatarUrl,
dimension: 40,
displayName ??
members[index]
.stateKey!
.localpart,
),
),
),
), ),
_ => throw Exception( _ => throw Exception(
"Member content was not MembershipContent", "Member content was not MembershipContent",

View file

@ -1,7 +1,8 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.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/helpers/extensions/string_to_color.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/extensions/displayname_color.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";
import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart";
@ -11,9 +12,9 @@ class const MembershipRenderer(
final Event event, { final Event event, {
final int? maxLines, final int? maxLines,
super.key, super.key,
}) extends StatelessWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
assert( assert(
event.content is MembershipContent, event.content is MembershipContent,
"Make sure to only pass membership events to MembershipRenderer", "Make sure to only pass membership events to MembershipRenderer",
@ -40,7 +41,10 @@ class const MembershipRenderer(
overflow: .ellipsis, overflow: .ellipsis,
content.displayName ?? event.stateKey!.localpart, content.displayName ?? event.stateKey!.localpart,
maxLines: 1, maxLines: 1,
style: .new(color: event.sender.colorHash, fontWeight: .bold), style: .new(
color: ref.displaynameColor(event.roomId, event.stateKey!),
fontWeight: .bold,
),
), ),
), ),
), ),