reorganize and rename widgets
This commit is contained in:
parent
05d25869ef
commit
d5f29dc344
5 changed files with 568 additions and 547 deletions
72
lib/widgets/users/mutual_rooms_sheet.dart
Normal file
72
lib/widgets/users/mutual_rooms_sheet.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/mutual_rooms.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/sidebar_page.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/focus_room.dart";
|
||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
||||
|
||||
/// The rooms the user shares with [userId].
|
||||
class const MutualRoomsSheet(final String userId, {super.key})
|
||||
extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
|
||||
title: Text("Mutual Rooms"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
|
||||
],
|
||||
children: [
|
||||
ref
|
||||
.watch(MutualRoomsController.provider(userId))
|
||||
.betterWhen(
|
||||
data: (roomIds) {
|
||||
final rooms = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(allRooms) => roomIds
|
||||
.map((roomId) => allRooms[roomId]?.metadata)
|
||||
.nonNulls
|
||||
.toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
return rooms.isEmpty
|
||||
? Text("No mutual rooms")
|
||||
: Column(
|
||||
children: rooms
|
||||
.map(
|
||||
(room) => ListTile(
|
||||
leading: AvatarOrHash(
|
||||
room.avatar,
|
||||
room.name ?? "Unnamed Room",
|
||||
fallback: Icon(Icons.numbers),
|
||||
),
|
||||
title: Text(
|
||||
room.name ?? "Unnamed Room",
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () async {
|
||||
final container = ProviderScope.containerOf(
|
||||
context,
|
||||
);
|
||||
// Closes the user popover under this too
|
||||
Navigator.of(context)
|
||||
.popUntil((route) => route.isFirst);
|
||||
await container.focusRoom(room.id);
|
||||
container
|
||||
.read(
|
||||
SidebarPageController.provider.notifier,
|
||||
)
|
||||
.set(.rooms);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
78
lib/widgets/users/start_dm_sheet.dart
Normal file
78
lib/widgets/users/start_dm_sheet.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/create_room.dart";
|
||||
|
||||
/// Starts a DM, asking for a user ID unless [userId] is given. Pops with true
|
||||
/// if a DM was started.
|
||||
class const StartDmSheet({final String? userId, final String? name, super.key})
|
||||
extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userIdController = useTextEditingController();
|
||||
final text = useValueListenable(userIdController).text.trim();
|
||||
final target = userId ?? text;
|
||||
final valid = target.isValidUserId;
|
||||
final encrypted = useState(true);
|
||||
final reasonController = useTextEditingController();
|
||||
|
||||
void submit() {
|
||||
if (!valid) return;
|
||||
context.startDirectMessage(
|
||||
target,
|
||||
name: name ?? target,
|
||||
encrypted: encrypted.value,
|
||||
inviteReason: reasonController.text.trim().isEmpty
|
||||
? null
|
||||
: reasonController.text.trim(),
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: const Text("Start a Direct Message"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: valid ? submit : null,
|
||||
child: const Text("Start"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
if (userId == null)
|
||||
TextField(
|
||||
controller: userIdController,
|
||||
autofocus: true,
|
||||
keyboardType: .emailAddress,
|
||||
onSubmitted: (_) => submit(),
|
||||
decoration: .new(
|
||||
labelText: "User ID",
|
||||
hintText: "@user:example.com",
|
||||
errorText: text.isEmpty || valid ? null : "Enter a valid user ID",
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
"Are you sure you want to start a direct message with ${name ?? userId}?",
|
||||
),
|
||||
TextField(
|
||||
controller: reasonController,
|
||||
textCapitalization: .sentences,
|
||||
onSubmitted: (_) => submit(),
|
||||
decoration: const .new(labelText: "Invite reason (optional)"),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.lock),
|
||||
title: const Text("Encrypted"),
|
||||
subtitle: const Text("Can't be turned off later"),
|
||||
value: encrypted.value,
|
||||
onChanged: (value) => encrypted.value = value,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
103
lib/widgets/users/timezone_field.dart
Normal file
103
lib/widgets/users/timezone_field.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/timezones.dart";
|
||||
import "package:nexus/helpers/extensions/weighted_search.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
|
||||
.search(query, (timezone) => [(timezone, 1)])
|
||||
.map(
|
||||
(timezone) => ListTile(
|
||||
leading: Icon(
|
||||
timezone == value ? Icons.check : Icons.schedule,
|
||||
),
|
||||
title: Text(timezone.replaceAll("_", " ")),
|
||||
subtitle: Text(offsetOf(timezone)),
|
||||
onTap: () => choose(controller, timezone),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
530
lib/widgets/users/user_bottom_sheet.dart
Normal file
530
lib/widgets/users/user_bottom_sheet.dart
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:m3e_buttons/m3e_buttons.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/mutual_rooms.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/profile.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/user.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/create_room.dart";
|
||||
import "package:nexus/helpers/extensions/success_scheme.dart";
|
||||
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/widgets/common/error_dialog.dart";
|
||||
import "package:nexus/widgets/common/loading.dart";
|
||||
import "package:nexus/widgets/users/mutual_rooms_sheet.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/membership_action.dart";
|
||||
import "package:nexus/models/profile_response.dart";
|
||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/avatars/editable_avatar.dart";
|
||||
import "package:nexus/widgets/common/expandable_image.dart";
|
||||
import "package:nexus/widgets/settings/inline_text_field.dart";
|
||||
import "package:nexus/widgets/users/start_dm_sheet.dart";
|
||||
import "package:nexus/widgets/users/timezone_field.dart";
|
||||
|
||||
final class const UserBottomSheet(
|
||||
final String userId, {
|
||||
final String? roomId,
|
||||
|
||||
/// Edits the current user's global profile, instead of showing actions.
|
||||
final bool editable = false,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final subtitleStyle = textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
void showMembershipSheet(MembershipAction action) =>
|
||||
context.showAdaptiveSheet(
|
||||
(context) => HookBuilder(
|
||||
builder: (context) {
|
||||
final actionReasonController = useTextEditingController();
|
||||
return SheetLayout(
|
||||
title: Text(
|
||||
"${toBeginningOfSentenceCase(action.name)} $userId",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
client
|
||||
.setMembership(
|
||||
.new(
|
||||
userId: userId,
|
||||
roomId: roomId!,
|
||||
action: action,
|
||||
reason: actionReasonController.text,
|
||||
),
|
||||
)
|
||||
.onError(showError);
|
||||
},
|
||||
child: Text(toBeginningOfSentenceCase(action.name)),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text("Are you sure you want to ${action.name} $userId?"),
|
||||
TextField(
|
||||
textCapitalization: .sentences,
|
||||
controller: actionReasonController,
|
||||
decoration: .new(
|
||||
labelText: "Reason for ${action.name} (optional)",
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return switch (ref.watch(
|
||||
UserController.provider(.new(roomId: roomId, userId: userId)),
|
||||
)) {
|
||||
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||
error,
|
||||
stackTrace,
|
||||
),
|
||||
AsyncData(value: final member) ||
|
||||
AsyncLoading(value: final member?) => SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: .all(editable ? 16 : 42),
|
||||
child: Column(
|
||||
spacing: 4,
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .center,
|
||||
children: [
|
||||
if (!editable) ...[
|
||||
Row(
|
||||
mainAxisAlignment: .end,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (userId !=
|
||||
ref.watch(ClientStateController.provider)?.userId)
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final ignored = ref.watch(
|
||||
AccountDataController.provider.select(
|
||||
(data) => data.ignoredUserList.contains(userId),
|
||||
),
|
||||
);
|
||||
final name = member.displayName ?? userId.localpart;
|
||||
Future<void> setIgnored(
|
||||
bool ignored,
|
||||
) => context.withProgressSnackbar(
|
||||
"${ignored ? "Blocking" : "Unblocking"} $name...",
|
||||
() => ref
|
||||
.read(AccountDataController.provider.notifier)
|
||||
.setIgnored(userId, ignored: ignored),
|
||||
);
|
||||
|
||||
return M3EButton(
|
||||
tooltip: ignored ? "Unblock" : "Block",
|
||||
onPressed: ignored
|
||||
? () => setIgnored(false)
|
||||
: () => context.showAdaptiveSheet(
|
||||
(context) => SheetLayout(
|
||||
title: Text("Block $name"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
setIgnored(true);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
theme.colorScheme.error,
|
||||
),
|
||||
child: Text("Block"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
"Are you sure you want to block $userId? You won't see their new messages or invites.",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
style: .tonal,
|
||||
child: Icon(
|
||||
ignored ? Icons.lock_open : Icons.block,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
M3EButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 18),
|
||||
],
|
||||
|
||||
if (editable)
|
||||
EditableAvatar(
|
||||
hasAvatar: member.avatarUrl != null,
|
||||
onChanged: (url) => setProfileField(
|
||||
Profile.avatarUrlKey,
|
||||
url?.toString() ?? "",
|
||||
),
|
||||
child: avatarOf(member),
|
||||
)
|
||||
else
|
||||
avatarOf(member),
|
||||
|
||||
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(
|
||||
member.displayName ?? userId.localpart,
|
||||
style: textTheme.headlineLarge,
|
||||
textAlign: .center,
|
||||
),
|
||||
SelectableText(userId, textAlign: .center, style: subtitleStyle),
|
||||
|
||||
ref
|
||||
.watch(ProfileController.provider(userId))
|
||||
.betterWhen(
|
||||
// Keeps showing the profile while it's fetched again after saving
|
||||
skipLoadingOnRefresh: true,
|
||||
loading: () => Text(""),
|
||||
data: (profileResponse) => Column(
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: .center,
|
||||
alignment: .center,
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: editable
|
||||
? [
|
||||
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(
|
||||
(pronoun) =>
|
||||
// TODO: Check system language (l10n)
|
||||
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(
|
||||
(index, pronoun) => [
|
||||
if (index != 0)
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 4,
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
pronoun.summary,
|
||||
textAlign: .center,
|
||||
style: subtitleStyle,
|
||||
),
|
||||
],
|
||||
)
|
||||
.flattened,
|
||||
|
||||
if (profileResponse.profile.timezone !=
|
||||
null) ...[
|
||||
if (profileResponse
|
||||
.profile
|
||||
.pronouns
|
||||
.isNotEmpty)
|
||||
divider,
|
||||
Text(
|
||||
profileResponse.profile.timezone!,
|
||||
textAlign: .center,
|
||||
style: subtitleStyle,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!editable &&
|
||||
userId != ref.watch(ClientStateController.provider)?.userId)
|
||||
switch (ref.watch(MutualRoomsController.provider(userId))) {
|
||||
AsyncData(value: final roomIds) => switch (ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => roomIds.where(rooms.containsKey).length,
|
||||
),
|
||||
)) {
|
||||
0 => SizedBox.shrink(),
|
||||
final count => TextButton(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(_) => MutualRoomsSheet(userId),
|
||||
),
|
||||
child: Text(
|
||||
"$count mutual ${Intl.plural(count, one: "room", other: "rooms")}",
|
||||
),
|
||||
),
|
||||
},
|
||||
_ => SizedBox.shrink(),
|
||||
},
|
||||
|
||||
SizedBox(height: 8),
|
||||
if (editable ||
|
||||
userId !=
|
||||
ref.watch(ClientStateController.provider)?.userId) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: M3EButton.icon(
|
||||
onPressed: editable
|
||||
? null
|
||||
: () async {
|
||||
final name =
|
||||
member.displayName ?? userId.localpart;
|
||||
final hasDm = ref
|
||||
.read(RoomsController.provider)
|
||||
.values
|
||||
.any(
|
||||
(room) =>
|
||||
room.metadata?.dmUserId == userId,
|
||||
);
|
||||
|
||||
if (hasDm) {
|
||||
context.startDirectMessage(
|
||||
userId,
|
||||
name: name,
|
||||
);
|
||||
} else if (await context
|
||||
.showAdaptiveSheet<bool>(
|
||||
(_) => StartDmSheet(
|
||||
userId: userId,
|
||||
name: name,
|
||||
),
|
||||
) !=
|
||||
true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
shape: .square,
|
||||
style: .tonal,
|
||||
icon: Icon(Icons.message),
|
||||
label: Text("Message"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (roomId != null &&
|
||||
member.status == .knock &&
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.membershipAction(
|
||||
action: .invite,
|
||||
roomId: roomId!,
|
||||
targetUser: userId,
|
||||
),
|
||||
),
|
||||
))
|
||||
Padding(
|
||||
padding: .only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: M3EButton.icon(
|
||||
onPressed: () => client
|
||||
.setMembership(
|
||||
.new(
|
||||
userId: userId,
|
||||
roomId: roomId!,
|
||||
action: .invite,
|
||||
),
|
||||
)
|
||||
.onError(showError),
|
||||
shape: .square,
|
||||
icon: Icon(Icons.check),
|
||||
label: Text("Accept knock"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: theme.successScheme.primary,
|
||||
foregroundColor: theme.successScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Shown as a moderator would see it, while editing
|
||||
if (editable ||
|
||||
(roomId != null &&
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.membershipAction(
|
||||
action: .kick,
|
||||
roomId: roomId!,
|
||||
targetUser: userId,
|
||||
),
|
||||
),
|
||||
)))
|
||||
Padding(
|
||||
padding: .only(top: 4),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (editable ||
|
||||
switch (member.status) {
|
||||
.join || .invite || .knock => true,
|
||||
_ => false,
|
||||
})
|
||||
M3EButton.icon(
|
||||
onPressed: editable
|
||||
? null
|
||||
: () => showMembershipSheet(.kick),
|
||||
shape: .square,
|
||||
icon: Icon(Icons.sports_martial_arts),
|
||||
label: Text("Kick"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
),
|
||||
|
||||
M3EButton.icon(
|
||||
onPressed: editable
|
||||
? null
|
||||
: () => showMembershipSheet(.ban),
|
||||
shape: .square,
|
||||
icon: Icon(Icons.gavel),
|
||||
label: Text("Ban"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: theme.colorScheme.errorContainer,
|
||||
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
].map((e) => Expanded(child: e)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AsyncLoading _ => Loading(),
|
||||
};
|
||||
}
|
||||
}
|
||||
63
lib/widgets/users/user_span.dart
Normal file
63
lib/widgets/users/user_span.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import "package:flutter/gestures.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||
import "package:nexus/controllers/author.dart";
|
||||
import "package:nexus/helpers/extensions/displayname_color.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
/// A user's [name] in text, opening their popover when tapped. Unlike a widget
|
||||
/// in text, it wraps and ellipsizes with the rest of it.
|
||||
class UserSpan extends TextSpan {
|
||||
UserSpan(
|
||||
BuildContext context,
|
||||
String name, {
|
||||
required String userId,
|
||||
required TapGestureRecognizer recognizer,
|
||||
String? roomId,
|
||||
super.style,
|
||||
super.children,
|
||||
}) : super(
|
||||
text: name,
|
||||
recognizer: recognizer
|
||||
..onTapUp = (details) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
context.showUserPopover(
|
||||
userId,
|
||||
roomId: roomId,
|
||||
anchor: context.globalRectAt(details.globalPosition),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
factory UserSpan.sender(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Event event, {
|
||||
required TapGestureRecognizer recognizer,
|
||||
}) {
|
||||
final color = ref.displaynameColor(event.roomId, event.sender);
|
||||
|
||||
return UserSpan(
|
||||
context,
|
||||
ref.watch(AuthorController.provider(event)).value?.displayName ??
|
||||
event.sender.localpart,
|
||||
userId: event.sender,
|
||||
roomId: event.roomId,
|
||||
recognizer: recognizer,
|
||||
style: TextStyle(color: color, fontWeight: .bold),
|
||||
children: [
|
||||
if (event.pmp != null)
|
||||
TextSpan(
|
||||
text: " (via ${event.sender})",
|
||||
recognizer: recognizer,
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(color: color, fontWeight: .bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue