9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
910 lines
33 KiB
Dart
910 lines
33 KiB
Dart
part of '../channels_page.dart';
|
|
|
|
const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60;
|
|
|
|
class _CreateChannelMenuOption<T> {
|
|
final Key? key;
|
|
final String label;
|
|
final T value;
|
|
|
|
const _CreateChannelMenuOption({
|
|
this.key,
|
|
required this.label,
|
|
required this.value,
|
|
});
|
|
}
|
|
|
|
const _createChannelTtlOptions = [
|
|
_CreateChannelMenuOption(label: '30 minutes', value: 30 * 60),
|
|
_CreateChannelMenuOption(label: '1 hour', value: 60 * 60),
|
|
_CreateChannelMenuOption(label: '6 hours', value: 6 * 60 * 60),
|
|
_CreateChannelMenuOption(label: '12 hours', value: 12 * 60 * 60),
|
|
_CreateChannelMenuOption(label: '1 day', value: 24 * 60 * 60),
|
|
_CreateChannelMenuOption(label: '3 days', value: 3 * 24 * 60 * 60),
|
|
_CreateChannelMenuOption(
|
|
label: '7 days',
|
|
value: _defaultCreateChannelTtlSeconds,
|
|
),
|
|
_CreateChannelMenuOption(label: '14 days', value: 14 * 24 * 60 * 60),
|
|
_CreateChannelMenuOption(label: '30 days', value: 30 * 24 * 60 * 60),
|
|
];
|
|
|
|
String _localizedTtlLabel(BuildContext context, int seconds) {
|
|
final l10n = context.l10n;
|
|
if (seconds % (24 * 60 * 60) == 0) {
|
|
return l10n.durationDays(seconds ~/ (24 * 60 * 60));
|
|
}
|
|
if (seconds % (60 * 60) == 0) {
|
|
return l10n.durationHours(seconds ~/ (60 * 60));
|
|
}
|
|
return l10n.durationMinutes(seconds ~/ 60);
|
|
}
|
|
|
|
class _CreateChannelSheet extends HookConsumerWidget {
|
|
final String channelType;
|
|
|
|
const _CreateChannelSheet({required this.channelType});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final nameController = useTextEditingController();
|
|
final descriptionController = useTextEditingController();
|
|
final visibility = useState('open');
|
|
final temporary = useState(false);
|
|
final ttlSeconds = useState(_defaultCreateChannelTtlSeconds);
|
|
final isSubmitting = useState(false);
|
|
final errorMessage = useState<String?>(null);
|
|
useListenable(nameController);
|
|
|
|
final kindLabel = channelType == 'forum'
|
|
? context.l10n.forum
|
|
: context.l10n.channel;
|
|
final ttlOptions = [
|
|
for (final option in _createChannelTtlOptions)
|
|
_CreateChannelMenuOption(
|
|
key: option.key,
|
|
label: _localizedTtlLabel(context, option.value),
|
|
value: option.value,
|
|
),
|
|
];
|
|
final canSubmit =
|
|
nameController.text.trim().isNotEmpty && !isSubmitting.value;
|
|
|
|
Future<void> submit() async {
|
|
final name = nameController.text.trim();
|
|
if (name.isEmpty || isSubmitting.value) {
|
|
return;
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
errorMessage.value = null;
|
|
try {
|
|
final created = await ref
|
|
.read(channelActionsProvider)
|
|
.createChannel(
|
|
name: name,
|
|
channelType: channelType,
|
|
visibility: visibility.value,
|
|
description: descriptionController.text.trim(),
|
|
ttlSeconds: temporary.value ? ttlSeconds.value : null,
|
|
);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop(created);
|
|
}
|
|
} catch (error) {
|
|
errorMessage.value = error.toString();
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
}
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
Grid.gutter,
|
|
0,
|
|
Grid.gutter,
|
|
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.createNewKind(kindLabel),
|
|
style: context.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.3,
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.sm),
|
|
_CreateChannelFieldLabel(label: context.l10n.sectionName),
|
|
const SizedBox(height: Grid.xxs),
|
|
_CreateChannelFieldShell(
|
|
child: TextField(
|
|
key: const Key('create-channel-name'),
|
|
controller: nameController,
|
|
enabled: !isSubmitting.value,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
textCapitalization: TextCapitalization.none,
|
|
decoration: InputDecoration(
|
|
hintText: channelType == 'forum'
|
|
? context.l10n.forumNameHint
|
|
: context.l10n.channelNameHint,
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.twelve,
|
|
vertical: Grid.xs,
|
|
),
|
|
),
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (_) {
|
|
if (canSubmit) {
|
|
unawaited(submit());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
_CreateChannelFieldLabel(
|
|
label: context.l10n.description,
|
|
optional: true,
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
_CreateChannelFieldShell(
|
|
child: TextField(
|
|
key: const Key('create-channel-description'),
|
|
controller: descriptionController,
|
|
enabled: !isSubmitting.value,
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.channelPurposeHint(kindLabel),
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.twelve,
|
|
vertical: Grid.xs,
|
|
),
|
|
),
|
|
minLines: 2,
|
|
maxLines: 3,
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (_) {
|
|
if (canSubmit) {
|
|
unawaited(submit());
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.sm),
|
|
_CreateChannelRadioGroup<bool>(
|
|
enabled: !isSubmitting.value,
|
|
label: context.l10n.channelType,
|
|
onSelected: (value) => temporary.value = value,
|
|
options: [
|
|
_CreateChannelMenuOption(
|
|
key: Key('create-channel-type-ongoing'),
|
|
label: context.l10n.ongoing,
|
|
value: false,
|
|
),
|
|
_CreateChannelMenuOption(
|
|
key: Key('create-channel-type-temporary'),
|
|
label: context.l10n.temporary,
|
|
value: true,
|
|
),
|
|
],
|
|
value: temporary.value,
|
|
),
|
|
if (temporary.value) ...[
|
|
const SizedBox(height: Grid.xs),
|
|
_CreateChannelSettingMenu<int>(
|
|
controlKey: const Key('create-channel-ttl'),
|
|
enabled: !isSubmitting.value,
|
|
label: context.l10n.expiresAfter,
|
|
onSelected: (value) => ttlSeconds.value = value,
|
|
options: ttlOptions,
|
|
value: ttlSeconds.value,
|
|
valueLabel: ttlOptions
|
|
.firstWhere((option) => option.value == ttlSeconds.value)
|
|
.label,
|
|
),
|
|
],
|
|
const SizedBox(height: Grid.sm),
|
|
_CreateChannelRadioGroup<String>(
|
|
enabled: !isSubmitting.value,
|
|
label: context.l10n.visibility,
|
|
onSelected: (value) => visibility.value = value,
|
|
options: [
|
|
_CreateChannelMenuOption(
|
|
key: Key('create-channel-visibility-public'),
|
|
label: context.l10n.publicVisibility,
|
|
value: 'open',
|
|
),
|
|
_CreateChannelMenuOption(
|
|
key: Key('create-channel-visibility-private'),
|
|
label: context.l10n.privateVisibility,
|
|
value: 'private',
|
|
),
|
|
],
|
|
value: visibility.value,
|
|
),
|
|
if (errorMessage.value case final error?) ...[
|
|
const SizedBox(height: Grid.xs),
|
|
Text(
|
|
error,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.error,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateChannelFieldLabel extends StatelessWidget {
|
|
final String label;
|
|
final bool optional;
|
|
|
|
const _CreateChannelFieldLabel({required this.label, this.optional = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(text: label),
|
|
if (optional)
|
|
TextSpan(
|
|
text: ' ${context.l10n.optional}',
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
style: context.textTheme.labelLarge?.copyWith(
|
|
color: context.colors.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateChannelFieldShell extends StatelessWidget {
|
|
final Widget child;
|
|
|
|
const _CreateChannelFieldShell({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: context.colors.primaryContainer.withValues(alpha: 0.55),
|
|
border: Border.all(color: context.colors.outlineVariant),
|
|
borderRadius: BorderRadius.circular(Radii.lg),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateChannelRadioGroup<T> extends StatelessWidget {
|
|
final bool enabled;
|
|
final String label;
|
|
final ValueChanged<T> onSelected;
|
|
final List<_CreateChannelMenuOption<T>> options;
|
|
final T value;
|
|
|
|
const _CreateChannelRadioGroup({
|
|
required this.enabled,
|
|
required this.label,
|
|
required this.onSelected,
|
|
required this.options,
|
|
required this.value,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_CreateChannelFieldLabel(label: label),
|
|
const SizedBox(height: Grid.xxs),
|
|
DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface,
|
|
border: Border.all(color: context.colors.outlineVariant),
|
|
borderRadius: BorderRadius.circular(Radii.lg),
|
|
),
|
|
child: RadioGroup<T>(
|
|
groupValue: value,
|
|
onChanged: (nextValue) {
|
|
if (enabled && nextValue != null) {
|
|
onSelected(nextValue);
|
|
}
|
|
},
|
|
child: Column(
|
|
children: [
|
|
for (final (index, option) in options.indexed) ...[
|
|
RadioListTile<T>(
|
|
key: option.key,
|
|
value: option.value,
|
|
enabled: enabled,
|
|
selected: option.value == value,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.xs,
|
|
vertical: Grid.half,
|
|
),
|
|
visualDensity: VisualDensity.standard,
|
|
activeColor: context.colors.primary,
|
|
title: Text(
|
|
option.label,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: enabled
|
|
? context.colors.onSurface
|
|
: context.colors.onSurface.withValues(alpha: 0.38),
|
|
fontWeight: option.value == value
|
|
? FontWeight.w600
|
|
: FontWeight.w400,
|
|
),
|
|
),
|
|
),
|
|
if (index < options.length - 1)
|
|
Divider(
|
|
height: 1,
|
|
indent: Grid.xs,
|
|
endIndent: Grid.xs,
|
|
color: context.colors.outlineVariant,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateChannelSettingMenu<T> extends StatelessWidget {
|
|
final Key controlKey;
|
|
final bool enabled;
|
|
final String label;
|
|
final ValueChanged<T> onSelected;
|
|
final List<_CreateChannelMenuOption<T>> options;
|
|
final T value;
|
|
final IconData? valueIcon;
|
|
final String valueLabel;
|
|
|
|
const _CreateChannelSettingMenu({
|
|
required this.controlKey,
|
|
required this.enabled,
|
|
required this.label,
|
|
required this.onSelected,
|
|
required this.options,
|
|
required this.value,
|
|
this.valueIcon,
|
|
required this.valueLabel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface,
|
|
border: Border.all(color: context.colors.outlineVariant),
|
|
borderRadius: BorderRadius.circular(Radii.lg),
|
|
),
|
|
child: PopupMenuButton<T>(
|
|
enabled: enabled,
|
|
initialValue: value,
|
|
onSelected: onSelected,
|
|
tooltip: '$label: $valueLabel',
|
|
itemBuilder: (context) => [
|
|
for (final option in options)
|
|
CheckedPopupMenuItem<T>(
|
|
checked: option.value == value,
|
|
value: option.value,
|
|
child: Text(option.label),
|
|
),
|
|
],
|
|
child: Padding(
|
|
key: controlKey,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.twelve,
|
|
vertical: Grid.xs,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: context.textTheme.labelLarge?.copyWith(
|
|
color: context.colors.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (valueIcon case final icon?) ...[
|
|
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
|
|
const SizedBox(width: Grid.half),
|
|
],
|
|
Text(
|
|
valueLabel,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: context.colors.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
Icon(
|
|
LucideIcons.chevronDown,
|
|
size: 16,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NewDirectMessageSheet extends HookConsumerWidget {
|
|
final String? currentPubkey;
|
|
|
|
const _NewDirectMessageSheet({required this.currentPubkey});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final queryController = useTextEditingController();
|
|
final queryFocusNode = useFocusNode();
|
|
final query = useState('');
|
|
final debouncedQuery = useState('');
|
|
final selectedUsers = useState<List<DirectoryUser>>([]);
|
|
final isSubmitting = useState(false);
|
|
final submitError = useState<String?>(null);
|
|
final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider);
|
|
|
|
useEffect(() {
|
|
final timer = Timer(const Duration(milliseconds: 250), () {
|
|
debouncedQuery.value = query.value.trim().toLowerCase();
|
|
});
|
|
return timer.cancel;
|
|
}, [query.value]);
|
|
|
|
final normalizedQuery = previewDirectoryActive
|
|
? query.value.trim().toLowerCase()
|
|
: debouncedQuery.value;
|
|
final isSearchTransitionPending =
|
|
!previewDirectoryActive &&
|
|
query.value.trim().toLowerCase() != normalizedQuery;
|
|
final directoryAsync = previewDirectoryActive
|
|
? AsyncValue.data(dmDirectoryPreviewUsers)
|
|
: normalizedQuery.isEmpty
|
|
? ref.watch(relayDirectoryUsersProvider)
|
|
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
|
|
|
|
final selectedPubkeys = selectedUsers.value
|
|
.map((user) => user.pubkey.toLowerCase())
|
|
.toSet();
|
|
final availableResults =
|
|
directoryAsync.asData?.value.where((user) {
|
|
final normalizedPubkey = user.pubkey.toLowerCase();
|
|
if (selectedPubkeys.contains(normalizedPubkey) ||
|
|
normalizedPubkey == currentPubkey?.toLowerCase()) {
|
|
return false;
|
|
}
|
|
if (normalizedQuery.isEmpty) {
|
|
return true;
|
|
}
|
|
return user.label.toLowerCase().contains(normalizedQuery) ||
|
|
user.secondaryLabel.toLowerCase().contains(normalizedQuery) ||
|
|
normalizedPubkey.contains(normalizedQuery);
|
|
}).toList() ??
|
|
const <DirectoryUser>[];
|
|
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
|
|
|
|
Future<void> submit() async {
|
|
if (selectedUsers.value.isEmpty || isSubmitting.value) {
|
|
return;
|
|
}
|
|
if (previewDirectoryActive) {
|
|
submitError.value = context.l10n.previewDmUnavailable;
|
|
return;
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
submitError.value = null;
|
|
try {
|
|
final channel = await ref
|
|
.read(channelActionsProvider)
|
|
.openDm(
|
|
pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(),
|
|
);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop(channel);
|
|
}
|
|
} catch (error) {
|
|
submitError.value = error.toString();
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
}
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
Grid.gutter,
|
|
0,
|
|
Grid.gutter,
|
|
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.newMessage,
|
|
style: context.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.3,
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: ConstrainedBox(
|
|
key: const Key('new-dm-recipient-field'),
|
|
constraints: const BoxConstraints(minHeight: Grid.xl),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface,
|
|
border: Border.all(
|
|
color: context.colors.outlineVariant,
|
|
),
|
|
borderRadius: BorderRadius.circular(Radii.lg),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.twelve,
|
|
vertical: Grid.xxs,
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final hasSelectedUsers =
|
|
selectedUsers.value.isNotEmpty;
|
|
final searchFieldWidth = hasSelectedUsers
|
|
? 96.0
|
|
: (constraints.maxWidth - 32).clamp(
|
|
96.0,
|
|
constraints.maxWidth,
|
|
);
|
|
|
|
return Wrap(
|
|
key: const Key('new-dm-recipient-wrap'),
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: Grid.half,
|
|
),
|
|
child: Text(
|
|
context.l10n.toLabel,
|
|
style: context.textTheme.bodyLarge
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
for (final user in selectedUsers.value)
|
|
_SelectedDmRecipientChip(
|
|
user: user,
|
|
enabled: !isSubmitting.value,
|
|
onDeleted: () {
|
|
selectedUsers.value = [
|
|
for (final candidate
|
|
in selectedUsers.value)
|
|
if (candidate.pubkey != user.pubkey)
|
|
candidate,
|
|
];
|
|
queryFocusNode.requestFocus();
|
|
},
|
|
),
|
|
SizedBox(
|
|
width: searchFieldWidth,
|
|
child: TextField(
|
|
key: const Key('new-dm-search'),
|
|
controller: queryController,
|
|
focusNode: queryFocusNode,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
enabled: !isSubmitting.value,
|
|
onChanged: (value) => query.value = value,
|
|
onSubmitted: (_) {
|
|
if (canSubmit) {
|
|
unawaited(submit());
|
|
}
|
|
},
|
|
decoration: InputDecoration(
|
|
hintText: hasSelectedUsers
|
|
? null
|
|
: context.l10n.searchPerson,
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(
|
|
vertical: Grid.half,
|
|
),
|
|
suffixIcon: isSubmitting.value
|
|
? Padding(
|
|
padding: EdgeInsets.all(
|
|
Grid.half,
|
|
),
|
|
child: SizedBox.square(
|
|
dimension: 16,
|
|
child: BuzzLoadingIndicator(
|
|
size: 16,
|
|
semanticLabel:
|
|
context
|
|
.l10n
|
|
.creatingConversation,
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
suffixIconConstraints:
|
|
const BoxConstraints(
|
|
minHeight: 32,
|
|
minWidth: 32,
|
|
),
|
|
),
|
|
textInputAction: TextInputAction.done,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
Builder(
|
|
key: const Key('new-dm-results'),
|
|
builder: (context) {
|
|
if (selectedUsers.value.length >= 8) {
|
|
return SizedBox(
|
|
height: 96,
|
|
child: Center(
|
|
child: Text(
|
|
context.l10n.dmPeopleLimit,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (directoryAsync.isLoading &&
|
|
directoryAsync.asData == null ||
|
|
isSearchTransitionPending) {
|
|
return SizedBox(
|
|
height: 280,
|
|
child: Center(
|
|
child: BuzzLoadingIndicator(
|
|
size: 44,
|
|
semanticLabel: context.l10n.loadingPeople,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (directoryAsync.hasError) {
|
|
return SizedBox(
|
|
height: 280,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(context.l10n.peopleLoadFailed),
|
|
const SizedBox(height: Grid.half),
|
|
TextButton(
|
|
onPressed: () {
|
|
if (normalizedQuery.isEmpty) {
|
|
ref.invalidate(relayDirectoryUsersProvider);
|
|
} else {
|
|
ref.invalidate(
|
|
relayDirectorySearchProvider(
|
|
normalizedQuery,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Text(context.l10n.retry),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (availableResults.isEmpty) {
|
|
return SizedBox(
|
|
height: 280,
|
|
child: Center(
|
|
child: Text(
|
|
normalizedQuery.isEmpty
|
|
? context.l10n.noPeopleAvailable
|
|
: context.l10n.noMatchingUsers,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return ListView.separated(
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
shrinkWrap: true,
|
|
itemCount: availableResults.length,
|
|
separatorBuilder: (_, _) =>
|
|
const Divider(height: 1, indent: 56),
|
|
itemBuilder: (context, index) {
|
|
final user = availableResults[index];
|
|
return ListTile(
|
|
key: Key('new-dm-person-${user.pubkey}'),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.half,
|
|
),
|
|
leading: AvatarImage(
|
|
imageUrl: user.avatarUrl,
|
|
radius: 20,
|
|
backgroundColor: context.colors.primaryContainer,
|
|
fallback: Text(
|
|
user.initial,
|
|
style: context.textTheme.labelLarge?.copyWith(
|
|
color: context.colors.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
title: Text(
|
|
user.label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: Text(
|
|
user.secondaryLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: Icon(
|
|
LucideIcons.plus,
|
|
size: 18,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
onTap: isSubmitting.value
|
|
? null
|
|
: () {
|
|
selectedUsers.value = [
|
|
...selectedUsers.value,
|
|
user,
|
|
];
|
|
queryController.clear();
|
|
query.value = '';
|
|
debouncedQuery.value = '';
|
|
submitError.value = null;
|
|
queryFocusNode.requestFocus();
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
if (submitError.value case final error?) ...[
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
error,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.error,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SelectedDmRecipientChip extends StatelessWidget {
|
|
final DirectoryUser user;
|
|
final bool enabled;
|
|
final VoidCallback onDeleted;
|
|
|
|
const _SelectedDmRecipientChip({
|
|
required this.user,
|
|
required this.enabled,
|
|
required this.onDeleted,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final foreground = context.colors.onSurfaceVariant;
|
|
|
|
return ConstrainedBox(
|
|
key: Key('new-dm-selected-${user.pubkey}'),
|
|
constraints: const BoxConstraints(maxWidth: 224),
|
|
child: SizedBox(
|
|
height: 40,
|
|
child: Material(
|
|
color: context.colors.surfaceContainerHighest,
|
|
shape: const StadiumBorder(),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Semantics(
|
|
button: true,
|
|
enabled: enabled,
|
|
excludeSemantics: true,
|
|
label: context.l10n.removePerson(user.label),
|
|
child: InkWell(
|
|
customBorder: const StadiumBorder(),
|
|
onTap: enabled ? onDeleted : null,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: Grid.half,
|
|
right: Grid.twelve,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AvatarImage(
|
|
imageUrl: user.avatarUrl,
|
|
radius: 16,
|
|
backgroundColor: context.colors.primaryContainer,
|
|
fallback: Text(
|
|
user.initial,
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Flexible(
|
|
child: Text(
|
|
user.label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.textTheme.bodyLarge?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|