add profile editor

Fixes #12
This commit is contained in:
Henry Hiles 2026-10-22 21:20:13 -04:00
commit 8fbfcde9c5
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
11 changed files with 639 additions and 102 deletions

View file

@ -307,6 +307,15 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<void> setProfileField(String field, Object? value) => _sendCommand(
"set_profile_field",
{
"field": field,
// gomuks deletes the field when the value is omitted, not when it's null
"value": ?value,
},
);
Future<void> reportEvent(ReportRequest request) => Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson()); _sendCommand("report_event", request.toJson());

View file

@ -10,6 +10,15 @@ class ProfileController(final String userId)
return client.getProfile(userId); return client.getProfile(userId);
} }
Future<void> setField(String field, Object? value) async {
await ref
.read(ClientController.provider.notifier)
.setProfileField(field, value);
if (!ref.mounted) return;
ref.invalidateSelf();
await future;
}
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<ProfileController, ProfileResponse, String>( .autoDispose<ProfileController, ProfileResponse, String>(
ProfileController.new, ProfileController.new,

View file

@ -18,12 +18,14 @@ import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/models/room_order.dart"; import "package:nexus/models/room_order.dart";
import "package:nexus/models/settings.dart"; import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart"; import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -240,9 +242,28 @@ class SettingsSectionsController
]), ]),
), ),
]), ]),
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) if (ref.watch(ClientStateController.provider) case ClientState(
isLoggedIn: true,
:final userId?,
))
"Account": .new([ "Account": .new([
.new(title: "Profile", icon: Icons.person, settings: .new([])), .new(
title: "Profile",
icon: Icons.person,
settings: .new([
.new(
title: "Profile",
description: "Your avatar, display name, pronouns, and timezone, as others see them.",
icon: Icons.person,
builder: (title, description, icon) => Center(
child: ConstrainedBox(
constraints: .new(maxWidth: 500),
child: UserBottomSheet(userId, editable: true),
),
),
),
]),
),
.new( .new(
title: "Notifications", title: "Notifications",
icon: Icons.notifications, icon: Icons.notifications,

View file

@ -0,0 +1,17 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:timezone/data/latest_10y.dart";
import "package:timezone/timezone.dart";
/// The names of all IANA timezones, e.g. `Europe/London`, alphabetically.
class TimezonesController extends Notifier<IList<String>> {
@override
IList<String> build() {
initializeTimeZones();
return timeZoneDatabase.locations.keys.toIList().sort();
}
static final provider = NotifierProvider<TimezonesController, IList<String>>(
TimezonesController.new,
);
}

View file

@ -31,24 +31,46 @@ class const Bio({required final String html, final String? editSource})
class const Profile({ class const Profile({
final String? id, final String? id,
final String? parseError, final String? parseError,
final Uri? avatarUrl, @JsonKey(name: Profile.avatarUrlKey) final Uri? avatarUrl,
@JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson) @JsonKey(
name: Profile.displayNameKey,
fromJson: MembershipContent.displaynameFromJson,
)
final String? displayName, final String? displayName,
@JsonKey(readValue: Profile.readTimezone, name: "m.tz") @JsonKey(readValue: Profile.readTimezone, name: Profile.timezoneKey)
final String? timezone, final String? timezone,
@JsonKey(readValue: Profile.readPronouns, name: "m.pronouns") @JsonKey(readValue: Profile.readPronouns, name: Profile.pronounsKey)
final IList<Pronoun> pronouns = const .empty(), final IList<Pronoun> pronouns = const .empty(),
}) with _$Profile { }) with _$Profile {
static const avatarUrlKey = "avatar_url";
static const displayNameKey = "displayname";
static const timezoneKey = "m.tz";
static const unstableTimezoneKey = "us.cloke.msc4175.tz";
static const pronounsKey = "m.pronouns";
static const unstablePronounsKey = "io.fsky.nyx.pronouns";
Map<String, Object?> toJson() => _$ProfileToJson(this); Map<String, Object?> toJson() => _$ProfileToJson(this);
Profile withPronouns(String language, Iterable<String> summaries) => copyWith(
pronouns: pronouns
.where((pronoun) => pronoun.language != language)
.toIList()
.addAll(
summaries.map(
(summary) => Pronoun(language: language, summary: summary),
),
),
);
static Object? readPronouns(Map<dynamic, dynamic> map, String key) => static Object? readPronouns(Map<dynamic, dynamic> map, String key) =>
map[key] ?? map["io.fsky.nyx.pronouns"]; /* until stable, commented out: map[key] ?? */ map[Profile
.unstablePronounsKey];
static Object? readTimezone(Map<dynamic, dynamic> map, String key) => static Object? readTimezone(Map<dynamic, dynamic> map, String key) =>
map[key] ?? map["us.cloke.msc4175.tz"]; map[key] ?? map[Profile.unstableTimezoneKey];
factory Profile.fromJson(Map<String, dynamic> json) => factory Profile.fromJson(Map<String, dynamic> json) =>
_$ProfileFromJson(json); _$ProfileFromJson(json);

View file

@ -0,0 +1,86 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/message.dart";
/// Shows [child], an avatar, with a button to upload a new one or remove it.
class const EditableAvatar({
required final Widget child,
/// Whether there's an avatar to remove.
required final bool hasAvatar,
/// Called with the uploaded image, or null when the avatar is removed.
required final Future<void> Function(Uri? url) onChanged,
final double dimension = 200,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final saving = Saving.use();
Future<void> save(Future<void> Function() action) =>
saving.run(() => action().onError(showError));
Future<void> pick() async {
final file = await ref
.read(FileDialogHelper.provider)
.pickFile(images: true);
if (file == null || !context.mounted) return;
await save(() async {
final content = await ref
.read(ClientController.provider.notifier)
.uploadMedia(.new(path: file.path, encrypt: false));
if (content case ImageMessageContent(:final url)) {
await onChanged(url);
} else {
throw Exception("The file isn't an image");
}
});
}
return SizedBox.square(
dimension: dimension,
child: Stack(
children: [
child,
if (saving.active) const Center(child: CircularProgressIndicator()),
Positioned(
right: dimension * 0.01,
bottom: dimension * 0.01,
child: Builder(
builder: (context) => IconButton.filled(
tooltip: "Edit avatar",
icon: Icon(Icons.edit),
onPressed: saving.active
? null
: !hasAvatar
? pick
: () => context.showAdaptiveMenu([
PopupMenuItem(
onTap: pick,
child: ListTile(
leading: Icon(Icons.upload),
title: Text("Upload new avatar"),
),
),
PopupMenuItem(
onTap: () => save(() => onChanged(null)),
child: ListTile(
leading: Icon(Icons.delete),
title: Text("Remove avatar"),
),
),
]),
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,131 @@
import "dart:async";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/hooks/saving.dart";
/// Centered text that's edited in place, saving when submitted or unfocused.
class const InlineTextField({
required final String? value,
required final String hint,
/// Disables the field if null, and while a returned future runs.
required final FutureOr<void> Function(String value)? onChanged,
final TextStyle? style,
/// Pads the start to match the edit icon, keeping the text centered when the
/// field is on its own, rather than beside others.
final bool balanced = true,
super.key,
}) extends HookWidget {
static double iconSizeOf(TextStyle? style) =>
((style?.fontSize ?? 16) * 0.7).clamp(16.0, 24.0);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final saving = Saving.use();
final controller = useTextEditingController(text: value);
final isEmpty = useValueListenable(controller).text.isEmpty;
final focusNode = useFocusNode();
final focused = useListenableSelector(focusNode, () => focusNode.hasFocus);
final latest = useRef(value);
latest.value = value;
// Show changes made elsewhere, unless they'd overwrite an edit. Effects run
// while building, so this waits until after, to not rebuild the field mid-build.
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && !focusNode.hasFocus) {
controller.text = value ?? "";
}
});
return null;
}, [value]);
useEffect(() {
Future<void> listener() async {
if (focusNode.hasFocus) return;
final text = controller.text.trim();
if (text != (latest.value ?? "")) {
await saving.run(() => onChanged?.call(text));
}
if (context.mounted && !focusNode.hasFocus) {
controller.text = latest.value ?? "";
}
}
focusNode.addListener(listener);
return () => focusNode.removeListener(listener);
}, [focusNode, onChanged]);
const border = UnderlineInputBorder(borderRadius: .zero, borderSide: .none);
final iconSize = iconSizeOf(style);
final iconConstraints = BoxConstraints.tight(Size(iconSize + 12, iconSize));
return CallbackShortcuts(
bindings: {
const SingleActivator(.escape): () {
controller.text = value ?? "";
focusNode.unfocus();
},
},
// Sized to the text, so the edit icon sits beside it
child: IntrinsicWidth(
child: TextField(
controller: controller,
focusNode: focusNode,
enabled: onChanged != null && !saving.active,
style: style,
textAlign: .center,
textCapitalization: .sentences,
// Wraps long text, but stays one line, with enter saving it
maxLines: null,
keyboardType: .text,
textInputAction: .done,
inputFormatters: [FilteringTextInputFormatter.singleLineFormatter],
onSubmitted: (_) => focusNode.unfocus(),
onTapOutside: (_) => focusNode.unfocus(),
decoration: .new(
// The hint counts towards the field's width, so it's only set when it shows
hintText: isEmpty ? hint : null,
hintStyle: style?.copyWith(color: colorScheme.onSurfaceVariant),
filled: true,
fillColor: Colors.transparent,
hoverColor: colorScheme.surfaceContainerHighest,
isDense: true,
// Without the prefix, this keeps the text off the edge of the hover background
contentPadding: EdgeInsetsDirectional.only(
start: balanced ? 0 : 6,
top: 8,
bottom: 8,
),
border: border,
focusedBorder: border.copyWith(
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
prefixIcon: balanced ? const SizedBox() : null,
prefixIconConstraints: iconConstraints,
suffixIcon: saving.active
? Center(
child: SizedBox.square(
dimension: iconSize - 4,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: Icon(
Icons.edit,
size: iconSize,
color: focused
? colorScheme.onSurfaceVariant.withValues(alpha: 0.38)
: colorScheme.onSurfaceVariant,
),
suffixIconConstraints: iconConstraints,
),
),
),
);
}
}

View file

@ -0,0 +1,102 @@
import "dart:async";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/timezones.dart";
import "package:nexus/helpers/extensions/is_desktop.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/widgets/settings/inline_text_field.dart";
import "package:timezone/timezone.dart";
class const TimezoneField({
required final String? value,
required final FutureOr<void> Function(String? value)? onChanged,
final TextStyle? style,
super.key,
}) extends HookConsumerWidget {
static String offsetOf(String timezone) {
final offset = TZDateTime.now(getLocation(timezone)).timeZoneOffset;
final hours = offset.inHours.abs().toString().padLeft(2, "0");
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, "0");
return "UTC${offset.isNegative ? "-" : "+"}$hours:$minutes";
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final saving = Saving.use();
final timezones = ref.watch(TimezonesController.provider);
final enabled = onChanged != null && !saving.active;
final iconSize = InlineTextField.iconSizeOf(style);
void choose(SearchController controller, String? timezone) {
controller.closeView("");
if (timezone != value) saving.run(() => onChanged!(timezone));
}
return SearchAnchor(
viewHintText: "Search timezones...",
viewConstraints: context.isDesktop
? const .new(minWidth: 320, maxWidth: 400, maxHeight: 400)
: null,
builder: (context, controller) => InkWell(
hoverColor: colorScheme.surfaceContainerHighest,
onTap: enabled ? controller.openView : null,
child: Padding(
padding: const .fromLTRB(6, 8, 0, 8),
child: Row(
mainAxisSize: .min,
children: [
Text(
value?.replaceAll("_", " ") ?? "Timezone",
style: value == null
? style?.copyWith(color: colorScheme.onSurfaceVariant)
: style,
),
SizedBox(
width: iconSize + 12,
child: saving.active
? Center(
child: SizedBox.square(
dimension: iconSize - 4,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: Icon(
Icons.edit,
size: iconSize,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
suggestionsBuilder: (context, controller) {
final query = controller.text.trim().toLowerCase().replaceAll(" ", "_");
return [
if (value != null && query.isEmpty)
ListTile(
leading: Icon(Icons.close),
title: Text("Remove timezone"),
onTap: () => choose(controller, null),
),
...timezones
.where((timezone) => timezone.toLowerCase().contains(query))
.map(
(timezone) => ListTile(
leading: Icon(
timezone == value ? Icons.check : Icons.schedule,
),
title: Text(timezone.replaceAll("_", " ")),
subtitle: Text(offsetOf(timezone)),
onTap: () => choose(controller, timezone),
),
),
];
},
);
}
}

View file

@ -1,4 +1,5 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:dynamic_color/dynamic_color.dart"; import "package:dynamic_color/dynamic_color.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
@ -20,15 +21,23 @@ import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/mutual_rooms_sheet.dart"; import "package:nexus/widgets/mutual_rooms_sheet.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_action.dart"; import "package:nexus/models/membership_action.dart";
import "package:nexus/models/profile_response.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/editable_avatar.dart";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/settings/inline_text_field.dart";
import "package:nexus/widgets/start_dm_sheet.dart"; import "package:nexus/widgets/start_dm_sheet.dart";
import "package:nexus/widgets/timezone_field.dart";
final class const UserBottomSheet( final class const UserBottomSheet(
final String userId, { final String userId, {
final String? roomId, final String? roomId,
/// Edits the current user's global profile, instead of showing actions.
final bool editable = false,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
@ -39,6 +48,38 @@ final class const UserBottomSheet(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
); );
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final profileController = ref.read(
ProfileController.provider(userId).notifier,
);
Future<void> setProfileField(String field, Object? value) =>
profileController.setField(field, value).onError(showError);
final profile = ref
.watch(ProfileController.provider(userId))
.value
?.profile;
final englishPronouns = (profile?.pronouns ?? const IList<Pronoun>.empty())
// TODO: Check system language (l10n)
.where((pronoun) => pronoun.language == "en");
final divider = SizedBox(
height: 16,
child: VerticalDivider(
thickness: 1.5,
width: 4,
color: theme.colorScheme.onSurfaceVariant,
),
);
Widget avatarOf(MembershipContent member) => ExpandableImage(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,
dimension: 200,
),
);
// Green for accepting knocks, harmonized so it fits the current theme // Green for accepting knocks, harmonized so it fits the current theme
final acceptScheme = ColorScheme.fromSeed( final acceptScheme = ColorScheme.fromSeed(
seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary),
@ -101,12 +142,13 @@ final class const UserBottomSheet(
AsyncData(value: final member) || AsyncData(value: final member) ||
AsyncLoading(value: final member?) => SingleChildScrollView( AsyncLoading(value: final member?) => SingleChildScrollView(
child: Padding( child: Padding(
padding: .all(42), padding: .all(editable ? 16 : 42),
child: Column( child: Column(
spacing: 4, spacing: 4,
mainAxisSize: .min, mainAxisSize: .min,
crossAxisAlignment: .center, crossAxisAlignment: .center,
children: [ children: [
if (!editable) ...[
Row( Row(
mainAxisAlignment: .end, mainAxisAlignment: .end,
children: [ children: [
@ -117,19 +159,33 @@ final class const UserBottomSheet(
], ],
), ),
SizedBox(height: 18), SizedBox(height: 18),
],
ExpandableImage( if (editable)
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), EditableAvatar(
downloadFilename: member.displayName ?? userId.localpart, hasAvatar: member.avatarUrl != null,
child: AvatarOrHash( onChanged: (url) => setProfileField(
member.avatarUrl, Profile.avatarUrlKey,
member.displayName ?? userId.localpart, url?.toString() ?? "",
dimension: 200,
),
), ),
child: avatarOf(member),
)
else
avatarOf(member),
SizedBox(height: 8), SizedBox(height: 8),
if (editable)
InlineTextField(
value: profile?.displayName,
hint: userId.localpart,
style: textTheme.headlineLarge,
onChanged: profile == null
? null
: (value) =>
setProfileField(Profile.displayNameKey, value),
)
else
SelectableText( SelectableText(
member.displayName ?? userId.localpart, member.displayName ?? userId.localpart,
style: textTheme.headlineLarge, style: textTheme.headlineLarge,
@ -140,6 +196,8 @@ final class const UserBottomSheet(
ref ref
.watch(ProfileController.provider(userId)) .watch(ProfileController.provider(userId))
.betterWhen( .betterWhen(
// Keeps showing the profile while it's fetched again after saving
skipLoadingOnRefresh: true,
loading: () => Text(""), loading: () => Text(""),
data: (profileResponse) => Column( data: (profileResponse) => Column(
children: [ children: [
@ -148,20 +206,77 @@ final class const UserBottomSheet(
alignment: .center, alignment: .center,
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
children: [ children: editable
...profileResponse.profile.pronouns ? [
InlineTextField(
value: englishPronouns
.map((pronoun) => pronoun.summary)
.join(", "),
hint: "Pronouns, e.g. they/them",
balanced: false,
style: subtitleStyle,
onChanged: (value) {
final pronouns = profileResponse
.profile
.pronouns
.where( .where(
(pronoun) =>
// TODO: Check system language (l10n) // TODO: Check system language (l10n)
(pronoun) => pronoun.language == "en", pronoun.language != "en",
) )
.toIList()
.addAll(
value
.split(",")
.map(
(summary) => summary.trim(),
)
.where(
(summary) =>
summary.isNotEmpty,
)
.map(
(summary) => Pronoun(
language: "en",
summary: summary,
),
),
);
return setProfileField(
Profile.unstablePronounsKey,
pronouns.isEmpty
? null
: pronouns
.map(
(pronoun) =>
pronoun.toJson(),
)
.toList(),
);
},
),
divider,
TimezoneField(
value: profileResponse.profile.timezone,
style: subtitleStyle,
onChanged: (value) => setProfileField(
Profile.timezoneKey,
value,
),
),
]
: [
...englishPronouns
.mapIndexed( .mapIndexed(
(index, pronoun) => [ (index, pronoun) => [
if (index != 0) if (index != 0)
Icon( Icon(
Icons.circle, Icons.circle,
size: 4, size: 4,
color: color: theme
theme.colorScheme.onSurfaceVariant, .colorScheme
.onSurfaceVariant,
), ),
Text( Text(
pronoun.summary, pronoun.summary,
@ -172,16 +287,13 @@ final class const UserBottomSheet(
) )
.flattened, .flattened,
if (profileResponse.profile.timezone != null) ...[ if (profileResponse.profile.timezone !=
if (profileResponse.profile.pronouns.isNotEmpty) null) ...[
SizedBox( if (profileResponse
height: 16, .profile
child: VerticalDivider( .pronouns
thickness: 1.5, .isNotEmpty)
width: 4, divider,
color: theme.colorScheme.onSurfaceVariant,
),
),
Text( Text(
profileResponse.profile.timezone!, profileResponse.profile.timezone!,
textAlign: .center, textAlign: .center,
@ -194,7 +306,8 @@ final class const UserBottomSheet(
), ),
), ),
if (userId != ref.watch(ClientStateController.provider)?.userId) if (!editable &&
userId != ref.watch(ClientStateController.provider)?.userId)
switch (ref.watch(MutualRoomsController.provider(userId))) { switch (ref.watch(MutualRoomsController.provider(userId))) {
AsyncData(value: final roomIds) => switch (ref.watch( AsyncData(value: final roomIds) => switch (ref.watch(
RoomsController.provider.select( RoomsController.provider.select(
@ -215,29 +328,45 @@ final class const UserBottomSheet(
}, },
SizedBox(height: 8), SizedBox(height: 8),
if (userId != if (editable ||
userId !=
ref.watch(ClientStateController.provider)?.userId) ...[ ref.watch(ClientStateController.provider)?.userId) ...[
Row( Row(
children: [ children: [
Expanded( Expanded(
child: M3EButton.icon( child: M3EButton.icon(
onPressed: () async { onPressed: editable
final name = member.displayName ?? userId.localpart; ? null
: () async {
final name =
member.displayName ?? userId.localpart;
final hasDm = ref final hasDm = ref
.read(RoomsController.provider) .read(RoomsController.provider)
.values .values
.any((room) => room.metadata?.dmUserId == userId); .any(
(room) =>
room.metadata?.dmUserId == userId,
);
if (hasDm) { if (hasDm) {
context.startDirectMessage(userId, name: name); context.startDirectMessage(
} else if (await context.showAdaptiveSheet<bool>( userId,
(_) => StartDmSheet(userId: userId, name: name), name: name,
);
} else if (await context
.showAdaptiveSheet<bool>(
(_) => StartDmSheet(
userId: userId,
name: name,
),
) != ) !=
true) { true) {
return; return;
} }
if (context.mounted) Navigator.of(context).pop(); if (context.mounted) {
Navigator.of(context).pop();
}
}, },
shape: .square, shape: .square,
style: .tonal, style: .tonal,
@ -287,7 +416,9 @@ final class const UserBottomSheet(
), ),
), ),
if (roomId != null && // Shown as a moderator would see it, while editing
if (editable ||
(roomId != null &&
ref.watch( ref.watch(
PowerLevelController.provider( PowerLevelController.provider(
.membershipAction( .membershipAction(
@ -296,15 +427,21 @@ final class const UserBottomSheet(
targetUser: userId, targetUser: userId,
), ),
), ),
)) )))
Padding( Padding(
padding: .only(top: 4), padding: .only(top: 4),
child: Row( child: Row(
spacing: 8, spacing: 8,
children: [ children: [
if (member.status case .join || .invite || .knock) if (editable ||
switch (member.status) {
.join || .invite || .knock => true,
_ => false,
})
M3EButton.icon( M3EButton.icon(
onPressed: () => showMembershipSheet(.kick), onPressed: editable
? null
: () => showMembershipSheet(.kick),
shape: .square, shape: .square,
icon: Icon(Icons.sports_martial_arts), icon: Icon(Icons.sports_martial_arts),
label: Text("Kick"), label: Text("Kick"),
@ -315,7 +452,9 @@ final class const UserBottomSheet(
), ),
M3EButton.icon( M3EButton.icon(
onPressed: () => showMembershipSheet(.ban), onPressed: editable
? null
: () => showMembershipSheet(.ban),
shape: .square, shape: .square,
icon: Icon(Icons.gavel), icon: Icon(Icons.gavel),
label: Text("Ban"), label: Text("Ban"),

View file

@ -1488,7 +1488,7 @@ packages:
source: hosted source: hosted
version: "3.7.1" version: "3.7.1"
timezone: timezone:
dependency: transitive dependency: "direct main"
description: description:
name: timezone name: timezone
sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb"

View file

@ -110,6 +110,7 @@ dependencies:
unifiedpush: 6.2.0 unifiedpush: 6.2.0
unifiedpush_storage_shared_preferences: 1.0.0 unifiedpush_storage_shared_preferences: 1.0.0
flutter_local_notifications: 22.3.1 flutter_local_notifications: 22.3.1
timezone: 0.11.1
material_emoji_picker: 1.2.0 material_emoji_picker: 1.2.0
xdg_desktop_portal: xdg_desktop_portal:
git: git: