mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Unify mobile channel details (#6113)
## Summary - combine channel identity, actions, member previews, and lifecycle controls in one navigable mobile details page - add compact member management and desktop-aligned channel editing - smooth the profile entrance transition and standardize settings-group spacing - align Huddle lifecycle rows with regular message author and body spacing ## Testing - `just mobile-check` - `just mobile-test` — 1,477 tests - Pixel 10 debug build, install, and interaction review ## Snapshots ### Channel header  ### Combined channel details  ### Manage channel  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -27,8 +27,8 @@ import '../channels/dm_channel_labels.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../../shared/read_state/read_state_format.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import 'activity_provider.dart';
|
||||
import 'compose_drafts_provider.dart';
|
||||
import 'inbox_item.dart';
|
||||
|
||||
268
mobile/lib/features/channels/add_members_sheet.dart
Normal file
268
mobile/lib/features/channels/add_members_sheet.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
|
||||
/// Searchable multi-select used to add people or agents to a channel.
|
||||
class AddChannelMembersSheet extends HookConsumerWidget {
|
||||
const AddChannelMembersSheet({
|
||||
super.key,
|
||||
required this.channelId,
|
||||
required this.existingPubkeys,
|
||||
});
|
||||
|
||||
final String channelId;
|
||||
final Set<String> existingPubkeys;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final queryController = useTextEditingController();
|
||||
final query = useState('');
|
||||
final debouncedQuery = useState('');
|
||||
final selectedUsers = useState<List<DirectoryUser>>([]);
|
||||
final isSubmitting = useState(false);
|
||||
final submitError = useState<String?>(null);
|
||||
|
||||
useEffect(() {
|
||||
final timer = Timer(const Duration(milliseconds: 250), () {
|
||||
debouncedQuery.value = query.value.trim().toLowerCase();
|
||||
});
|
||||
return timer.cancel;
|
||||
}, [query.value]);
|
||||
|
||||
final normalizedQuery = debouncedQuery.value;
|
||||
final directoryAsync = normalizedQuery.isEmpty
|
||||
? ref.watch(relayDirectoryUsersProvider)
|
||||
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
|
||||
final normalizedExisting = existingPubkeys
|
||||
.map((pubkey) => pubkey.toLowerCase())
|
||||
.toSet();
|
||||
final selectedPubkeys = selectedUsers.value
|
||||
.map((user) => user.pubkey.toLowerCase())
|
||||
.toSet();
|
||||
final successfulPubkeys = useState<Set<String>>(<String>{});
|
||||
final excludedPubkeys = {...normalizedExisting, ...successfulPubkeys.value};
|
||||
final availableUsers =
|
||||
directoryAsync.asData?.value
|
||||
.where(
|
||||
(user) => !excludedPubkeys.contains(user.pubkey.toLowerCase()),
|
||||
)
|
||||
.toList() ??
|
||||
const <DirectoryUser>[];
|
||||
|
||||
void toggleUser(DirectoryUser user) {
|
||||
if (isSubmitting.value) return;
|
||||
final normalized = user.pubkey.toLowerCase();
|
||||
selectedUsers.value = selectedPubkeys.contains(normalized)
|
||||
? [
|
||||
for (final selected in selectedUsers.value)
|
||||
if (selected.pubkey.toLowerCase() != normalized) selected,
|
||||
]
|
||||
: [...selectedUsers.value, user];
|
||||
submitError.value = null;
|
||||
}
|
||||
|
||||
Future<void> addSelectedMembers() async {
|
||||
if (selectedUsers.value.isEmpty || isSubmitting.value) return;
|
||||
final submittedUsers = List<DirectoryUser>.of(selectedUsers.value);
|
||||
isSubmitting.value = true;
|
||||
submitError.value = null;
|
||||
try {
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.addMembers(
|
||||
channelId: channelId,
|
||||
pubkeys: submittedUsers.map((user) => user.pubkey).toList(),
|
||||
);
|
||||
if (context.mounted) Navigator.of(context).pop(true);
|
||||
} on AddMembersException catch (error) {
|
||||
final failedPubkeys = error.failures.keys
|
||||
.map((pubkey) => pubkey.toLowerCase())
|
||||
.toSet();
|
||||
successfulPubkeys.value = {
|
||||
...successfulPubkeys.value,
|
||||
for (final user in submittedUsers)
|
||||
if (!failedPubkeys.contains(user.pubkey.toLowerCase()))
|
||||
user.pubkey.toLowerCase(),
|
||||
};
|
||||
selectedUsers.value = [
|
||||
for (final user in submittedUsers)
|
||||
if (failedPubkeys.contains(user.pubkey.toLowerCase())) user,
|
||||
];
|
||||
submitError.value = error.message;
|
||||
} catch (error) {
|
||||
submitError.value = error.toString();
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('add-channel-members-search'),
|
||||
controller: queryController,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !isSubmitting.value,
|
||||
onChanged: (value) => query.value = value,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search for people or agents',
|
||||
prefixIcon: Icon(LucideIcons.search),
|
||||
),
|
||||
),
|
||||
if (selectedUsers.value.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
spacing: Grid.half,
|
||||
runSpacing: Grid.half,
|
||||
children: [
|
||||
for (final user in selectedUsers.value)
|
||||
InputChip(
|
||||
key: ValueKey(
|
||||
'add-channel-member-selected-${user.pubkey}',
|
||||
),
|
||||
label: Text(user.label),
|
||||
onDeleted: isSubmitting.value
|
||||
? null
|
||||
: () => toggleUser(user),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Expanded(
|
||||
child: directoryAsync.when(
|
||||
data: (_) => availableUsers.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
normalizedQuery.isEmpty
|
||||
? 'Everyone available is already in this channel.'
|
||||
: 'No matching people or agents.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
key: const ValueKey('add-channel-members-results'),
|
||||
itemCount: availableUsers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = availableUsers[index];
|
||||
final selected = selectedPubkeys.contains(
|
||||
user.pubkey.toLowerCase(),
|
||||
);
|
||||
return Semantics(
|
||||
key: ValueKey(
|
||||
'add-channel-member-${user.pubkey}',
|
||||
),
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: selected
|
||||
? '${user.label}, selected'
|
||||
: user.label,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: AvatarImage(
|
||||
imageUrl: user.avatarUrl,
|
||||
radius: 20,
|
||||
backgroundColor:
|
||||
context.colors.primaryContainer,
|
||||
fallback: Text(user.initial),
|
||||
),
|
||||
title: Text(
|
||||
user.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
user.secondaryLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Icon(
|
||||
selected
|
||||
? LucideIcons.circleCheck
|
||||
: LucideIcons.plus,
|
||||
color: selected
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
onTap: () => toggleUser(user),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 44,
|
||||
semanticLabel: 'Loading people and agents',
|
||||
),
|
||||
),
|
||||
error: (error, _) => Center(
|
||||
child: Text(
|
||||
'Couldn’t load people or agents. Try again.',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (submitError.value case final error?) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.xxs),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('add-channel-members-submit'),
|
||||
onPressed: selectedUsers.value.isEmpty || isSubmitting.value
|
||||
? null
|
||||
: addSelectedMembers,
|
||||
child: Text(
|
||||
isSubmitting.value
|
||||
? 'Adding…'
|
||||
: selectedUsers.value.length == 1
|
||||
? 'Add member'
|
||||
: 'Add ${selectedUsers.value.length} members',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../shared/theme/theme.dart';
|
||||
import '../../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../../../shared/profile/user_cache_provider.dart';
|
||||
import '../date_formatters.dart';
|
||||
import 'observer_models.dart';
|
||||
import 'observer_subscription.dart';
|
||||
|
||||
@@ -141,16 +141,18 @@ class Channel {
|
||||
);
|
||||
|
||||
Channel copyWith({
|
||||
String? name,
|
||||
String? description,
|
||||
Object? lastMessageAt = _sentinel,
|
||||
Object? archivedAt = _sentinel,
|
||||
int? memberCount,
|
||||
bool? isMember,
|
||||
}) => Channel(
|
||||
id: id,
|
||||
name: name,
|
||||
name: name ?? this.name,
|
||||
channelType: channelType,
|
||||
visibility: visibility,
|
||||
description: description,
|
||||
description: description ?? this.description,
|
||||
topic: topic,
|
||||
purpose: purpose,
|
||||
createdBy: createdBy,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -8,19 +9,30 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/app_list.dart';
|
||||
import '../../shared/widgets/app_list_card.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_action_tile.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/sheet_divider.dart';
|
||||
import 'channel.dart';
|
||||
import 'add_members_sheet.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channel_mutes/channel_mutes_provider.dart';
|
||||
import 'channel_sections/channel_sections_provider.dart';
|
||||
import 'channel_stars/channel_stars_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'manage_channel_sheet.dart';
|
||||
import 'members_sheet.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
import '../../shared/read_state/read_state_time.dart';
|
||||
|
||||
part 'channel_details_page.dart';
|
||||
|
||||
/// Opens the mobile channel actions sheet and returns whether its parent page
|
||||
/// should close after a successful lifecycle action.
|
||||
Future<bool?> showChannelActionsSheet({
|
||||
@@ -47,7 +59,7 @@ Future<bool?> showChannelActionsSheet({
|
||||
|
||||
/// Mobile action sheet for channel-level read, organization, and lifecycle
|
||||
/// operations.
|
||||
class ChannelActionsSheet extends ConsumerWidget {
|
||||
class ChannelActionsSheet extends HookConsumerWidget {
|
||||
const ChannelActionsSheet({
|
||||
super.key,
|
||||
required this.channel,
|
||||
@@ -63,6 +75,8 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final displayedChannel = useState(channel);
|
||||
final currentChannel = displayedChannel.value;
|
||||
final isMuted =
|
||||
ref.watch(channelMutesProvider).store.channels[channel.id]?.muted ==
|
||||
true;
|
||||
@@ -207,7 +221,13 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
|
||||
),
|
||||
builder: (_) => ManageChannelSheet(channel: channel),
|
||||
builder: (_) => ManageChannelSheet(
|
||||
channel: currentChannel,
|
||||
canEditDetails:
|
||||
canManageLifecycle && !currentChannel.isArchived,
|
||||
onChannelUpdated: (updated) =>
|
||||
displayedChannel.value = updated,
|
||||
),
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
@@ -222,7 +242,7 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
close();
|
||||
copyToClipboard(
|
||||
context,
|
||||
channel.name,
|
||||
currentChannel.name,
|
||||
message: 'Channel name copied to clipboard',
|
||||
);
|
||||
},
|
||||
@@ -250,7 +270,7 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Leave #${channel.name}?',
|
||||
title: 'Leave #${currentChannel.name}?',
|
||||
body: 'You’ll stop receiving messages from this channel.',
|
||||
confirmLabel: 'Leave',
|
||||
action: () => ref
|
||||
@@ -281,7 +301,7 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Archive #${channel.name}?',
|
||||
title: 'Archive #${currentChannel.name}?',
|
||||
body: 'The channel will become read-only.',
|
||||
confirmLabel: 'Archive',
|
||||
action: () => ref
|
||||
@@ -296,7 +316,7 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Unarchive #${channel.name}?',
|
||||
title: 'Unarchive #${currentChannel.name}?',
|
||||
body: 'The channel will become active again.',
|
||||
confirmLabel: 'Unarchive',
|
||||
action: () => ref
|
||||
@@ -312,7 +332,7 @@ class ChannelActionsSheet extends ConsumerWidget {
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Delete #${channel.name}?',
|
||||
title: 'Delete #${currentChannel.name}?',
|
||||
body:
|
||||
'This permanently deletes the channel and cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
|
||||
@@ -23,8 +23,8 @@ import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/skeleton.dart';
|
||||
import '../profile/presence_cache_provider.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import '../forum/forum_posts_view.dart';
|
||||
import 'android_ime_lift.dart';
|
||||
import 'channel.dart';
|
||||
@@ -259,9 +259,10 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
!resolvedChannel.isForum &&
|
||||
isConnectionInProgress &&
|
||||
!messagesNotifier.hasLoadedMessages;
|
||||
final appBarTitleContentHeight = resolvedChannel.isDm
|
||||
? _dmAppBarTitleContentHeight(context)
|
||||
: 0.0;
|
||||
final appBarTitleContentHeight = _twoLineAppBarTitleContentHeight(
|
||||
context,
|
||||
isDm: resolvedChannel.isDm,
|
||||
);
|
||||
final readTimestamp = _channelReadTimestamp(
|
||||
channel: resolvedChannel,
|
||||
messagesState: messagesState,
|
||||
@@ -327,65 +328,53 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 22,
|
||||
child: Center(
|
||||
child: Icon(channelIcon(resolvedChannel), size: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
resolveDmChannelDisplayLabel(
|
||||
resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (resolvedChannel.isEphemeral) ...[
|
||||
const SizedBox(width: Grid.quarter),
|
||||
_HeaderEphemeralBadge(channel: resolvedChannel),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_showsMembersAction(resolvedChannel))
|
||||
_MembersButton(
|
||||
channelId: resolvedChannel.id,
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
IconButton(
|
||||
color: context.colors.primary,
|
||||
onPressed: () async {
|
||||
final shouldClose = await showChannelActionsSheet(
|
||||
context: context,
|
||||
: _ChannelAppBarTitle(
|
||||
channel: resolvedChannel,
|
||||
isUnread: false,
|
||||
sectionId: ref
|
||||
.read(channelSectionsProvider)
|
||||
.store
|
||||
.assignments[resolvedChannel.id],
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
tooltip: 'Channel actions',
|
||||
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
|
||||
),
|
||||
],
|
||||
onTap: () async {
|
||||
final shouldClose = await showChannelDetailsPage(
|
||||
context: context,
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
onMemberTap: showUserProfileSheet,
|
||||
sectionId: ref
|
||||
.read(channelSectionsProvider)
|
||||
.store
|
||||
.assignments[resolvedChannel.id],
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: resolvedChannel.isDm
|
||||
? [
|
||||
if (_showsMembersAction(resolvedChannel))
|
||||
_MembersButton(
|
||||
channelId: resolvedChannel.id,
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
IconButton(
|
||||
color: context.colors.primary,
|
||||
onPressed: () async {
|
||||
final shouldClose = await showChannelActionsSheet(
|
||||
context: context,
|
||||
channel: resolvedChannel,
|
||||
isUnread: false,
|
||||
sectionId: ref
|
||||
.read(channelSectionsProvider)
|
||||
.store
|
||||
.assignments[resolvedChannel.id],
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
tooltip: 'Channel actions',
|
||||
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
const _dmHeaderAvatarSize = 32.0;
|
||||
const _channelHeaderAvatarSize = 40.0;
|
||||
const _dmPresenceDotRatio = 8 / 14;
|
||||
|
||||
bool _showsMembersAction(Channel channel) {
|
||||
@@ -18,16 +19,106 @@ double _scaledTextHeight(BuildContext context, TextStyle style) {
|
||||
return scaledFontSize * (style.height ?? 1);
|
||||
}
|
||||
|
||||
double _dmAppBarTitleContentHeight(BuildContext context) {
|
||||
final titleStyle = context.textTheme.titleSmall;
|
||||
final presenceStyle = context.textTheme.bodyMedium;
|
||||
if (titleStyle == null || presenceStyle == null) {
|
||||
return _dmHeaderAvatarSize;
|
||||
double _twoLineAppBarTitleContentHeight(
|
||||
BuildContext context, {
|
||||
required bool isDm,
|
||||
}) {
|
||||
final titleStyle = isDm
|
||||
? context.textTheme.titleSmall
|
||||
: context.textTheme.titleMedium;
|
||||
final subtitleStyle = isDm
|
||||
? context.textTheme.bodyMedium
|
||||
: context.textTheme.bodySmall;
|
||||
final avatarSize = isDm ? _dmHeaderAvatarSize : _channelHeaderAvatarSize;
|
||||
if (titleStyle == null || subtitleStyle == null) {
|
||||
return avatarSize;
|
||||
}
|
||||
final textHeight =
|
||||
_scaledTextHeight(context, titleStyle) +
|
||||
_scaledTextHeight(context, presenceStyle);
|
||||
return textHeight > _dmHeaderAvatarSize ? textHeight : _dmHeaderAvatarSize;
|
||||
_scaledTextHeight(context, subtitleStyle);
|
||||
return textHeight > avatarSize ? textHeight : avatarSize;
|
||||
}
|
||||
|
||||
class _ChannelAppBarTitle extends ConsumerWidget {
|
||||
const _ChannelAppBarTitle({required this.channel, required this.onTap});
|
||||
|
||||
final Channel channel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final membersAsync = ref.watch(channelMembersProvider(channel.id));
|
||||
final memberCount = membersAsync.value?.length ?? channel.memberCount;
|
||||
final memberLabel =
|
||||
'$memberCount ${memberCount == 1 ? 'member' : 'members'}';
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open settings for ${channel.name}, $memberLabel',
|
||||
child: Tooltip(
|
||||
message: 'Open channel settings',
|
||||
child: InkWell(
|
||||
key: const ValueKey('channel-header-settings-trigger'),
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
key: const ValueKey('channel-header-avatar'),
|
||||
width: _channelHeaderAvatarSize,
|
||||
height: _channelHeaderAvatarSize,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
channelIcon(channel),
|
||||
size: 20,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
channel.name,
|
||||
key: const ValueKey('channel-header-name'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (channel.isEphemeral) ...[
|
||||
const SizedBox(width: Grid.quarter),
|
||||
_HeaderEphemeralBadge(channel: channel),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
memberLabel,
|
||||
key: const ValueKey('channel-header-member-count'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MembersButton extends ConsumerWidget {
|
||||
|
||||
@@ -378,28 +378,31 @@ class _MessageStyleSystemMessageContent extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -Grid.quarter),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.half),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MessageAuthorMeta(
|
||||
displayName: resolveLabel(displayPubkey),
|
||||
username: messageUsernameLabel(
|
||||
userCache[displayPubkey.toLowerCase()],
|
||||
),
|
||||
timestamp: formatMessageTime(createdAt),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor: context.colors.onSurfaceVariant,
|
||||
nameStyle: systemMessageHeadingTextStyle,
|
||||
displayNameKey: ValueKey(
|
||||
'system-message-author-$displayPubkey',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'system-message-username-$displayPubkey',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'system-message-timestamp-$displayPubkey',
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.quarter),
|
||||
child: MessageAuthorMeta(
|
||||
displayName: resolveLabel(displayPubkey),
|
||||
username: messageUsernameLabel(
|
||||
userCache[displayPubkey.toLowerCase()],
|
||||
),
|
||||
timestamp: formatMessageTime(createdAt),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor: context.colors.onSurfaceVariant,
|
||||
nameStyle: systemMessageHeadingTextStyle,
|
||||
displayNameKey: ValueKey(
|
||||
'system-message-author-$displayPubkey',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'system-message-username-$displayPubkey',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'system-message-timestamp-$displayPubkey',
|
||||
),
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
|
||||
687
mobile/lib/features/channels/channel_details_page.dart
Normal file
687
mobile/lib/features/channels/channel_details_page.dart
Normal file
@@ -0,0 +1,687 @@
|
||||
part of 'channel_actions_sheet.dart';
|
||||
|
||||
const _channelMemberPreviewLimit = 5;
|
||||
const _channelDetailsHeaderFrostScrollDistance = Grid.xxl;
|
||||
const _channelDetailsHeaderFrostMaxBlurSigma = 20.0;
|
||||
const _channelDetailsSectionPadding = Grid.twelve;
|
||||
|
||||
/// Opens the combined mobile channel settings page.
|
||||
Future<bool?> showChannelDetailsPage({
|
||||
required BuildContext context,
|
||||
required Channel channel,
|
||||
required String? currentPubkey,
|
||||
required void Function(BuildContext context, String pubkey) onMemberTap,
|
||||
String? sectionId,
|
||||
}) => Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) => ChannelDetailsPage(
|
||||
channel: channel,
|
||||
currentPubkey: currentPubkey,
|
||||
onMemberTap: onMemberTap,
|
||||
sectionId: sectionId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// Combined channel identity, membership, organization, and lifecycle page.
|
||||
class ChannelDetailsPage extends HookConsumerWidget {
|
||||
const ChannelDetailsPage({
|
||||
super.key,
|
||||
required this.channel,
|
||||
required this.currentPubkey,
|
||||
required this.onMemberTap,
|
||||
this.sectionId,
|
||||
});
|
||||
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
final void Function(BuildContext context, String pubkey) onMemberTap;
|
||||
final String? sectionId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final displayedChannel = useState(channel);
|
||||
final resolvedChannel = displayedChannel.value;
|
||||
final scrollController = useScrollController();
|
||||
final heroNameKey = useMemoized(GlobalKey.new);
|
||||
final collapsedTitleOffset = useRef<double?>(null);
|
||||
final showCollapsedTitle = useState(false);
|
||||
final headerFrostProgress = useState(0.0);
|
||||
final isJoining = useState(false);
|
||||
final isMuted =
|
||||
ref
|
||||
.watch(channelMutesProvider)
|
||||
.store
|
||||
.channels[resolvedChannel.id]
|
||||
?.muted ==
|
||||
true;
|
||||
final isStarred =
|
||||
ref
|
||||
.watch(channelStarsProvider)
|
||||
.store
|
||||
.channels[resolvedChannel.id]
|
||||
?.starred ==
|
||||
true;
|
||||
final membersAsync = ref.watch(channelMembersProvider(resolvedChannel.id));
|
||||
final members = membersAsync.asData?.value ?? const <ChannelMember>[];
|
||||
final agentOwnersAsync = ref.watch(agentOwnersProvider);
|
||||
final sectionState = ref.watch(channelSectionsProvider);
|
||||
final resolvedCurrentPubkey =
|
||||
currentPubkey?.toLowerCase() ??
|
||||
ref.watch(currentPubkeyProvider)?.toLowerCase();
|
||||
final currentMember = members.cast<ChannelMember?>().firstWhere(
|
||||
(member) => member?.pubkey.toLowerCase() == resolvedCurrentPubkey,
|
||||
orElse: () => null,
|
||||
);
|
||||
final ownsOwnerAgent =
|
||||
resolvedCurrentPubkey != null &&
|
||||
members.any(
|
||||
(member) =>
|
||||
member.isOwner &&
|
||||
agentOwnersAsync.value?[member.pubkey.toLowerCase()]
|
||||
?.toLowerCase() ==
|
||||
resolvedCurrentPubkey,
|
||||
);
|
||||
final canManageLifecycle =
|
||||
currentMember?.isElevated == true || ownsOwnerAgent;
|
||||
final canEdit = canManageLifecycle && !resolvedChannel.isArchived;
|
||||
final canJoin =
|
||||
resolvedChannel.visibility == 'open' &&
|
||||
!resolvedChannel.isArchived &&
|
||||
!resolvedChannel.isMember;
|
||||
final canAddMembers =
|
||||
membersAsync.hasValue &&
|
||||
!membersAsync.isLoading &&
|
||||
!membersAsync.hasError &&
|
||||
!resolvedChannel.isArchived &&
|
||||
resolvedChannel.canAddMembers(currentMember?.role);
|
||||
final canArchive = !resolvedChannel.isArchived && canManageLifecycle;
|
||||
final canUnarchive = resolvedChannel.isArchived && canManageLifecycle;
|
||||
final canDelete =
|
||||
!resolvedChannel.isArchived &&
|
||||
(currentMember?.isOwner == true || ownsOwnerAgent);
|
||||
final lifecycleCapabilitiesLoading =
|
||||
membersAsync.isLoading || agentOwnersAsync.isLoading;
|
||||
final lifecycleCapabilitiesUnavailable =
|
||||
membersAsync.hasError || agentOwnersAsync.hasError;
|
||||
final memberCount =
|
||||
membersAsync.value?.length ?? resolvedChannel.memberCount;
|
||||
final memberLabel =
|
||||
'$memberCount ${memberCount == 1 ? 'member' : 'members'}';
|
||||
final previewMembers = members.take(_channelMemberPreviewLimit).toList();
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final currentSectionId = sectionState.isReady
|
||||
? sectionState.store.assignments[resolvedChannel.id]
|
||||
: sectionId;
|
||||
final previewPubkeyKey = previewMembers
|
||||
.map((member) => member.pubkey.toLowerCase())
|
||||
.join('|');
|
||||
|
||||
useEffect(() {
|
||||
if (previewMembers.isNotEmpty) {
|
||||
ref
|
||||
.read(userCacheProvider.notifier)
|
||||
.preload(previewMembers.map((member) => member.pubkey).toList());
|
||||
}
|
||||
return null;
|
||||
}, [previewPubkeyKey]);
|
||||
|
||||
useEffect(() {
|
||||
void updateCollapsedTitle() {
|
||||
final nextFrostProgress = !scrollController.hasClients
|
||||
? 0.0
|
||||
: (scrollController.offset /
|
||||
_channelDetailsHeaderFrostScrollDistance)
|
||||
.clamp(0.0, 1.0)
|
||||
.toDouble();
|
||||
if ((headerFrostProgress.value - nextFrostProgress).abs() > 0.001) {
|
||||
headerFrostProgress.value = nextFrostProgress;
|
||||
}
|
||||
|
||||
final threshold = collapsedTitleOffset.value;
|
||||
if (threshold == null || !context.mounted) return;
|
||||
final nextValue = scrollController.offset >= threshold;
|
||||
if (showCollapsedTitle.value != nextValue) {
|
||||
showCollapsedTitle.value = nextValue;
|
||||
}
|
||||
}
|
||||
|
||||
collapsedTitleOffset.value = null;
|
||||
scrollController.addListener(updateCollapsedTitle);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final nameContext = heroNameKey.currentContext;
|
||||
final renderBox = nameContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null || !renderBox.hasSize || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
final nameBottom = renderBox
|
||||
.localToGlobal(Offset(0, renderBox.size.height))
|
||||
.dy;
|
||||
collapsedTitleOffset.value =
|
||||
scrollController.offset + nameBottom - frostedAppBarHeight(context);
|
||||
updateCollapsedTitle();
|
||||
});
|
||||
return () => scrollController.removeListener(updateCollapsedTitle);
|
||||
}, [scrollController, resolvedChannel.name]);
|
||||
|
||||
Future<void> openMembers() => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
title: 'Members',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => MembersSheet(
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: resolvedCurrentPubkey,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> openAddMembers() async {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
title: 'Add members',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight:
|
||||
mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.xs,
|
||||
),
|
||||
builder: (_) => AddChannelMembersSheet(
|
||||
channelId: resolvedChannel.id,
|
||||
existingPubkeys: {
|
||||
for (final member in members) member.pubkey.toLowerCase(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openManageChannel() async {
|
||||
final shouldClose = await showBuzzModalBottomSheet<bool>(
|
||||
context: context,
|
||||
title: 'Manage channel',
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
|
||||
),
|
||||
builder: (_) => ManageChannelSheet(
|
||||
channel: resolvedChannel,
|
||||
canEditDetails: canEdit,
|
||||
onChannelUpdated: (updated) => displayedChannel.value = updated,
|
||||
),
|
||||
);
|
||||
if (shouldClose == true && context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleStar() {
|
||||
final notifier = ref.read(channelStarsProvider.notifier);
|
||||
isStarred
|
||||
? notifier.unstarChannel(resolvedChannel.id)
|
||||
: notifier.starChannel(resolvedChannel.id);
|
||||
}
|
||||
|
||||
void toggleMute() {
|
||||
final notifier = ref.read(channelMutesProvider.notifier);
|
||||
isMuted
|
||||
? notifier.unmuteChannel(resolvedChannel.id)
|
||||
: notifier.muteChannel(resolvedChannel.id);
|
||||
}
|
||||
|
||||
Future<void> joinChannel() async {
|
||||
if (isJoining.value) return;
|
||||
isJoining.value = true;
|
||||
try {
|
||||
await ref.read(channelActionsProvider).joinChannel(resolvedChannel.id);
|
||||
if (context.mounted) Navigator.of(context).pop(false);
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Couldn’t join channel. Try again.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
return FrostedScaffold(
|
||||
backgroundColor: context.colors.surface,
|
||||
appBar: FrostedAppBar(
|
||||
iconColor: context.colors.primary,
|
||||
actions: const [SizedBox.square(dimension: 48)],
|
||||
frosted: headerFrostProgress.value > 0,
|
||||
frostedSurfaceOpacity: 0.5 * headerFrostProgress.value,
|
||||
frostedBlurSigma:
|
||||
_channelDetailsHeaderFrostMaxBlurSigma * headerFrostProgress.value,
|
||||
showBottomDivider: headerFrostProgress.value > 0,
|
||||
bottomDividerOpacity: 0.15 * headerFrostProgress.value,
|
||||
title: AnimatedSwitcher(
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 160),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
child: showCollapsedTitle.value
|
||||
? Text(
|
||||
resolvedChannel.name,
|
||||
key: const ValueKey('channel-details-collapsed-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: const SizedBox(
|
||||
key: ValueKey('channel-details-expanded-title-space'),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
key: const ValueKey('channel-details-page-list'),
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(context) + Grid.xs,
|
||||
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs,
|
||||
),
|
||||
children: [
|
||||
_ChannelDetailsHero(channel: resolvedChannel, nameKey: heroNameKey),
|
||||
Padding(
|
||||
key: const ValueKey('channel-details-actions'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
_channelDetailsSectionPadding,
|
||||
Grid.gutter,
|
||||
_channelDetailsSectionPadding,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: BuzzActionTile(
|
||||
key: const ValueKey('channel-details-star-action'),
|
||||
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
|
||||
label: isStarred ? 'Unstar' : 'Star channel',
|
||||
onTap: toggleStar,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: BuzzActionTile(
|
||||
key: const ValueKey('channel-details-mute-action'),
|
||||
icon: isMuted ? LucideIcons.bell : LucideIcons.bellOff,
|
||||
label: isMuted ? 'Unmute' : 'Mute channel',
|
||||
onTap: toggleMute,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: BuzzActionTile(
|
||||
key: const ValueKey('channel-details-edit-action'),
|
||||
icon: LucideIcons.pencil,
|
||||
label: 'Edit',
|
||||
isEnabled: canEdit,
|
||||
onTap: openManageChannel,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AppListCard(
|
||||
key: const ValueKey('channel-details-members-card'),
|
||||
label: memberLabel,
|
||||
dividerIndent: Grid.xs + 40 + Grid.xs,
|
||||
verticalPadding: _channelDetailsSectionPadding,
|
||||
children: [
|
||||
if (canAddMembers)
|
||||
AppListRowRaw(
|
||||
key: const ValueKey('channel-details-add-members-row'),
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.plus,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'Add members',
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
trailing: const _ChannelDetailsChevron(),
|
||||
onTap: openAddMembers,
|
||||
verticalPadding: Grid.xxs,
|
||||
),
|
||||
if (membersAsync.isLoading && members.isEmpty)
|
||||
const AppListRow(
|
||||
icon: LucideIcons.loaderCircle,
|
||||
title: 'Loading members…',
|
||||
trailing: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
semanticLabel: 'Loading members',
|
||||
),
|
||||
)
|
||||
else if (membersAsync.hasError && members.isEmpty)
|
||||
const AppListRow(
|
||||
icon: LucideIcons.triangleAlert,
|
||||
title: 'Members unavailable',
|
||||
)
|
||||
else ...[
|
||||
for (final member in previewMembers)
|
||||
_ChannelMemberPreviewRow(
|
||||
key: ValueKey('channel-details-member-${member.pubkey}'),
|
||||
member: member,
|
||||
currentPubkey: resolvedCurrentPubkey,
|
||||
onMemberTap: onMemberTap,
|
||||
displayName:
|
||||
userCache[member.pubkey.toLowerCase()]?.displayName,
|
||||
avatarUrl:
|
||||
userCache[member.pubkey.toLowerCase()]?.avatarUrl,
|
||||
),
|
||||
AppListRowRaw(
|
||||
key: const ValueKey('channel-details-members-row'),
|
||||
leading: const SizedBox.square(dimension: 40),
|
||||
title: Text('See all', style: context.textTheme.bodyLarge),
|
||||
trailing: const _ChannelDetailsChevron(),
|
||||
onTap: openMembers,
|
||||
verticalPadding: Grid.xxs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
AppListCard(
|
||||
key: const ValueKey('channel-details-channel-card'),
|
||||
verticalPadding: _channelDetailsSectionPadding,
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.folderInput,
|
||||
title: 'Move to section…',
|
||||
trailing: const _ChannelDetailsChevron(),
|
||||
onTap: () async {
|
||||
await _showMoveSectionSheet(
|
||||
context,
|
||||
ref,
|
||||
channel: resolvedChannel,
|
||||
sectionId: currentSectionId,
|
||||
);
|
||||
},
|
||||
),
|
||||
AppListRow(
|
||||
icon: LucideIcons.copy,
|
||||
title: 'Copy channel name',
|
||||
onTap: () {
|
||||
copyToClipboard(
|
||||
context,
|
||||
resolvedChannel.name,
|
||||
message: 'Channel name copied to clipboard',
|
||||
);
|
||||
},
|
||||
),
|
||||
AppListRow(
|
||||
icon: LucideIcons.hash,
|
||||
title: 'Copy channel ID',
|
||||
onTap: () {
|
||||
copyToClipboard(
|
||||
context,
|
||||
resolvedChannel.id,
|
||||
message: 'Channel ID copied to clipboard',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (resolvedChannel.isMember ||
|
||||
canJoin ||
|
||||
lifecycleCapabilitiesLoading ||
|
||||
lifecycleCapabilitiesUnavailable ||
|
||||
canArchive ||
|
||||
canUnarchive ||
|
||||
canDelete)
|
||||
AppListCard(
|
||||
key: const ValueKey('channel-details-lifecycle-card'),
|
||||
verticalPadding: _channelDetailsSectionPadding,
|
||||
children: [
|
||||
if (canJoin)
|
||||
AppListRow(
|
||||
icon: LucideIcons.logIn,
|
||||
title: isJoining.value
|
||||
? 'Joining channel…'
|
||||
: 'Join channel',
|
||||
onTap: isJoining.value ? null : joinChannel,
|
||||
),
|
||||
if (resolvedChannel.isMember && !resolvedChannel.isArchived)
|
||||
AppListRow(
|
||||
icon: LucideIcons.logOut,
|
||||
title: 'Leave channel',
|
||||
titleColor: context.colors.error,
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Leave #${resolvedChannel.name}?',
|
||||
body: 'You’ll stop receiving messages from this channel.',
|
||||
confirmLabel: 'Leave',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.leaveChannel(resolvedChannel.id),
|
||||
),
|
||||
),
|
||||
if (lifecycleCapabilitiesLoading)
|
||||
const AppListRow(
|
||||
icon: LucideIcons.loaderCircle,
|
||||
title: 'Loading channel actions…',
|
||||
trailing: BuzzLoadingIndicator(
|
||||
size: 20,
|
||||
semanticLabel: 'Loading channel actions',
|
||||
),
|
||||
)
|
||||
else if (lifecycleCapabilitiesUnavailable)
|
||||
const AppListRow(
|
||||
icon: LucideIcons.triangleAlert,
|
||||
title: 'Channel actions unavailable',
|
||||
)
|
||||
else ...[
|
||||
if (canArchive)
|
||||
AppListRow(
|
||||
icon: LucideIcons.archive,
|
||||
title: 'Archive channel',
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Archive #${resolvedChannel.name}?',
|
||||
body: 'The channel will become read-only.',
|
||||
confirmLabel: 'Archive',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.archiveChannel(resolvedChannel.id),
|
||||
),
|
||||
),
|
||||
if (canUnarchive)
|
||||
AppListRow(
|
||||
icon: LucideIcons.archiveRestore,
|
||||
title: 'Unarchive channel',
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Unarchive #${resolvedChannel.name}?',
|
||||
body: 'The channel will become active again.',
|
||||
confirmLabel: 'Unarchive',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.unarchiveChannel(resolvedChannel.id),
|
||||
),
|
||||
),
|
||||
if (canDelete)
|
||||
AppListRow(
|
||||
icon: LucideIcons.trash2,
|
||||
title: 'Delete channel',
|
||||
titleColor: context.colors.error,
|
||||
onTap: () => _confirmAndRun(
|
||||
context,
|
||||
ref,
|
||||
title: 'Delete #${resolvedChannel.name}?',
|
||||
body:
|
||||
'This permanently deletes the channel and cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
action: () => ref
|
||||
.read(channelActionsProvider)
|
||||
.deleteChannel(resolvedChannel.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelDetailsHero extends StatelessWidget {
|
||||
const _ChannelDetailsHero({required this.channel, required this.nameKey});
|
||||
|
||||
final Channel channel;
|
||||
final GlobalKey nameKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final description = channel.description.trim();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
Grid.gutter,
|
||||
_channelDetailsSectionPadding,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
key: const ValueKey('channel-details-avatar'),
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_channelDetailsIcon(channel),
|
||||
size: 32,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
KeyedSubtree(
|
||||
key: const ValueKey('channel-details-name'),
|
||||
child: Text(
|
||||
channel.name,
|
||||
key: nameKey,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (description.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
description,
|
||||
key: const ValueKey('channel-details-description'),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelMemberPreviewRow extends StatelessWidget {
|
||||
const _ChannelMemberPreviewRow({
|
||||
super.key,
|
||||
required this.member,
|
||||
required this.currentPubkey,
|
||||
required this.onMemberTap,
|
||||
required this.displayName,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
|
||||
final ChannelMember member;
|
||||
final String? currentPubkey;
|
||||
final void Function(BuildContext context, String pubkey) onMemberTap;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSelf = member.pubkey.toLowerCase() == currentPubkey?.toLowerCase();
|
||||
final label = isSelf
|
||||
? 'You'
|
||||
: displayName?.trim().isNotEmpty == true
|
||||
? displayName!.trim()
|
||||
: member.labelFor(currentPubkey);
|
||||
final roleLabel = _channelMemberRoleLabel(member.role);
|
||||
final titleStyle = context.textTheme.bodyLarge;
|
||||
final roleStyle = context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
);
|
||||
|
||||
return AppListRowRaw(
|
||||
leading: AvatarImage(
|
||||
imageUrl: avatarUrl,
|
||||
radius: 20,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Text(label.isEmpty ? '?' : label[0].toUpperCase()),
|
||||
),
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: titleStyle,
|
||||
children: [
|
||||
TextSpan(text: label),
|
||||
TextSpan(text: ' · $roleLabel', style: roleStyle),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const _ChannelDetailsChevron(),
|
||||
onTap: () => onMemberTap(context, member.pubkey),
|
||||
verticalPadding: Grid.xxs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _channelMemberRoleLabel(String role) {
|
||||
if (role == 'bot') return 'Agent';
|
||||
if (role.isEmpty) return 'Member';
|
||||
return '${role[0].toUpperCase()}${role.substring(1)}';
|
||||
}
|
||||
|
||||
IconData _channelDetailsIcon(Channel channel) {
|
||||
if (channel.isPrivate) return LucideIcons.lock;
|
||||
if (channel.isForum) return LucideIcons.messageSquareText;
|
||||
return LucideIcons.hash;
|
||||
}
|
||||
|
||||
class _ChannelDetailsChevron extends StatelessWidget {
|
||||
const _ChannelDetailsChevron();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
400
mobile/lib/features/channels/channel_management_actions.dart
Normal file
400
mobile/lib/features/channels/channel_management_actions.dart
Normal file
@@ -0,0 +1,400 @@
|
||||
part of 'channel_management_provider.dart';
|
||||
|
||||
/// Builds the relay tags for setting the archived state of [channelId].
|
||||
List<List<String>> buildSetChannelArchivedTags(
|
||||
String channelId, {
|
||||
required bool archived,
|
||||
}) => [
|
||||
['h', channelId],
|
||||
['archived', archived.toString()],
|
||||
];
|
||||
|
||||
/// Builds the relay tags for deleting [channelId].
|
||||
List<List<String>> buildDeleteChannelTags(String channelId) => [
|
||||
['h', channelId],
|
||||
];
|
||||
|
||||
class ChannelActions {
|
||||
final Ref _ref;
|
||||
final RelaySessionNotifier _session;
|
||||
final SignedEventRelay _signedEventRelay;
|
||||
final String? _currentPubkey;
|
||||
final bool Function()? _isCommunityValid;
|
||||
|
||||
ChannelActions({
|
||||
required Ref ref,
|
||||
required RelaySessionNotifier session,
|
||||
required SignedEventRelay signedEventRelay,
|
||||
required String? currentPubkey,
|
||||
bool Function()? isCommunityValid,
|
||||
}) : _ref = ref,
|
||||
_session = session,
|
||||
_signedEventRelay = signedEventRelay,
|
||||
_currentPubkey = currentPubkey,
|
||||
_isCommunityValid = isCommunityValid;
|
||||
|
||||
Future<Channel> createChannel({
|
||||
required String name,
|
||||
required String channelType,
|
||||
required String visibility,
|
||||
String? description,
|
||||
int? ttlSeconds,
|
||||
}) async {
|
||||
final channelId = _newUuidV4();
|
||||
final tags = buildCreateChannelTags(
|
||||
channelId: channelId,
|
||||
name: name,
|
||||
channelType: channelType,
|
||||
visibility: visibility,
|
||||
description: description,
|
||||
ttlSeconds: ttlSeconds,
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
|
||||
_ensureCommunityValid();
|
||||
return _refreshChannelsAndRead(channelId);
|
||||
}
|
||||
|
||||
/// Open (or create) a DM channel with the given pubkeys.
|
||||
///
|
||||
/// This submits a kind:41010 command event; the relay responds with an OK
|
||||
/// message whose content carries `response:{...}` containing the new
|
||||
/// `channel_id`.
|
||||
Future<Channel> openDm({required List<String> pubkeys}) async {
|
||||
_ensureCommunityValid();
|
||||
final result = await _signedEventRelay.submit(
|
||||
kind: 41010,
|
||||
content: '',
|
||||
tags: pubkeys.map((pk) => ['p', pk]).toList(),
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
final response = parseCommandResponse(result.content);
|
||||
final channelId = response?['channel_id'] as String?;
|
||||
if (channelId == null || channelId.isEmpty) {
|
||||
throw Exception('Relay did not return a DM channel id');
|
||||
}
|
||||
return _refreshChannelsAndRead(channelId);
|
||||
}
|
||||
|
||||
Future<void> addMembers({
|
||||
required String channelId,
|
||||
required List<String> pubkeys,
|
||||
String role = 'member',
|
||||
}) async {
|
||||
final normalizedRole = role.trim();
|
||||
if (normalizedRole.isEmpty) {
|
||||
throw ArgumentError.value(role, 'role', 'must not be empty');
|
||||
}
|
||||
final normalizedPubkeys = {
|
||||
for (final pubkey in pubkeys)
|
||||
if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(),
|
||||
};
|
||||
_ensureCommunityValid();
|
||||
// Per-pubkey failures are collected rather than thrown on the spot: one
|
||||
// relay rejection must not skip the remaining adds or the invalidation
|
||||
// below, which would leave the members list stale for the adds that landed.
|
||||
final failures = <String, String>{};
|
||||
for (final pubkey in normalizedPubkeys) {
|
||||
// Outside the catch: a community switch mid-loop must abort the whole
|
||||
// add, not be recorded as this pubkey's rejection.
|
||||
_ensureCommunityValid();
|
||||
try {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9000,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey],
|
||||
['role', normalizedRole],
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
failures[pubkey] = _relayErrorMessage(error);
|
||||
}
|
||||
}
|
||||
_ensureCommunityValid();
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
if (failures.isNotEmpty) {
|
||||
throw AddMembersException(failures);
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureCommunityValid() {
|
||||
if (_isCommunityValid?.call() == false) {
|
||||
throw StateError(
|
||||
'Channel action cancelled because the active community changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> joinChannel(String channelId) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9021,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
],
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
Future<void> leaveChannel(String channelId) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9022,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
],
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
/// Updates the user-editable channel metadata and refreshes cached details.
|
||||
Future<void> updateChannel({
|
||||
required String channelId,
|
||||
String? name,
|
||||
String? description,
|
||||
}) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9002,
|
||||
content: '',
|
||||
tags: buildUpdateChannelTags(
|
||||
channelId: channelId,
|
||||
name: name,
|
||||
description: description,
|
||||
),
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
/// Archives the channel and refreshes its cached state.
|
||||
Future<void> archiveChannel(String channelId) =>
|
||||
_setChannelArchived(channelId, archived: true);
|
||||
|
||||
/// Unarchives the channel and refreshes its cached state.
|
||||
Future<void> unarchiveChannel(String channelId) =>
|
||||
_setChannelArchived(channelId, archived: false);
|
||||
|
||||
Future<void> _setChannelArchived(
|
||||
String channelId, {
|
||||
required bool archived,
|
||||
}) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9002,
|
||||
content: '',
|
||||
tags: buildSetChannelArchivedTags(channelId, archived: archived),
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
/// Deletes the channel and refreshes its cached state.
|
||||
Future<void> deleteChannel(String channelId) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9008,
|
||||
content: '',
|
||||
tags: buildDeleteChannelTags(channelId),
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
Future<void> setCanvas({
|
||||
required String channelId,
|
||||
required String content,
|
||||
}) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 40100,
|
||||
content: content,
|
||||
tags: [
|
||||
['h', channelId],
|
||||
],
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
_ref.invalidate(channelCanvasProvider(channelId));
|
||||
}
|
||||
|
||||
/// User search via NIP-50 over kind:0 profile events.
|
||||
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
|
||||
final events = await _session.queryRelay([
|
||||
NostrFilters.searchUsers(trimmed, limit: limit),
|
||||
]);
|
||||
return directoryUsersFromProfileEvents(events)
|
||||
.where(
|
||||
(user) =>
|
||||
_currentPubkey == null ||
|
||||
user.pubkey.toLowerCase() != _currentPubkey,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Channel> _refreshChannelsAndRead(String channelId) async {
|
||||
_ensureCommunityValid();
|
||||
await _ref.read(channelsProvider.notifier).refresh();
|
||||
_ensureCommunityValid();
|
||||
final channels = await _ref.read(channelsProvider.future);
|
||||
_ensureCommunityValid();
|
||||
return channels.firstWhere(
|
||||
(channel) => channel.id == channelId,
|
||||
orElse: () =>
|
||||
throw Exception('Channel was created but is not visible yet'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshChannelState(String channelId) async {
|
||||
_ensureCommunityValid();
|
||||
await _ref.read(channelsProvider.notifier).refresh();
|
||||
_ensureCommunityValid();
|
||||
_ref.invalidate(channelDetailsProvider(channelId));
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
_ref.invalidate(channelCanvasProvider(channelId));
|
||||
}
|
||||
|
||||
String _newUuidV4() {
|
||||
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
final hex = bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return '${hex.substring(0, 8)}-'
|
||||
'${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-'
|
||||
'${hex.substring(16, 20)}-'
|
||||
'${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
Future<void> changeMemberRole({
|
||||
required String channelId,
|
||||
required String pubkey,
|
||||
required String role,
|
||||
}) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9000,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey.toLowerCase()],
|
||||
['role', role],
|
||||
],
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> removeMember({
|
||||
required String channelId,
|
||||
required String pubkey,
|
||||
}) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9001,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey.toLowerCase()],
|
||||
],
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> addReaction(String eventId, String emoji) async {
|
||||
final shortcode = normalizeShortcode(emoji);
|
||||
final emojiUrl = reactionEmojiUrl(
|
||||
emoji,
|
||||
_ref.read(customEmojiListProvider),
|
||||
);
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.reaction,
|
||||
content: emoji,
|
||||
tags: [
|
||||
['e', eventId],
|
||||
if (shortcode != null && emojiUrl != null)
|
||||
['emoji', shortcode, emojiUrl],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeReaction(String reactionEventId, String emoji) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.deletion,
|
||||
content: '',
|
||||
tags: [
|
||||
['e', reactionEventId],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> editMessage({
|
||||
required String channelId,
|
||||
required String eventId,
|
||||
required String content,
|
||||
List<List<String>> mediaTags = const [],
|
||||
}) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.streamMessageEdit,
|
||||
content: content,
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['e', eventId],
|
||||
...mediaTags,
|
||||
],
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
}
|
||||
|
||||
Future<void> deleteMessage({
|
||||
required String channelId,
|
||||
required String eventId,
|
||||
}) async {
|
||||
_ensureCommunityValid();
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.deletion,
|
||||
content: '',
|
||||
tags: buildDeleteMessageTags(channelId: channelId, eventId: eventId),
|
||||
);
|
||||
_ensureCommunityValid();
|
||||
}
|
||||
|
||||
static final Random _random = Random.secure();
|
||||
}
|
||||
|
||||
final channelActionsProvider = Provider<ChannelActions>((ref) {
|
||||
final relayConfig = ref.watch(relayConfigProvider);
|
||||
final currentPubkey = ref.watch(currentPubkeyProvider);
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
return ChannelActions(
|
||||
ref: ref,
|
||||
session: session,
|
||||
signedEventRelay: SignedEventRelay(
|
||||
session: session,
|
||||
nsec: relayConfig.nsec,
|
||||
),
|
||||
currentPubkey: currentPubkey,
|
||||
isCommunityValid: () {
|
||||
final currentConfig = ref.read(relayConfigProvider);
|
||||
return currentConfig.baseUrl == relayConfig.baseUrl &&
|
||||
currentConfig.nsec == relayConfig.nsec;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -11,8 +11,13 @@ import '../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_metadata_updates.dart';
|
||||
import 'channels_provider.dart';
|
||||
|
||||
export 'channel_metadata_updates.dart';
|
||||
|
||||
part 'channel_management_actions.dart';
|
||||
|
||||
String _relayErrorMessage(Object error) =>
|
||||
error.toString().replaceFirst('Exception: ', '');
|
||||
|
||||
@@ -614,355 +619,3 @@ List<List<String>> buildCreateChannelTags({
|
||||
if (ttlSeconds != null) ['ttl', ttlSeconds.toString()],
|
||||
];
|
||||
}
|
||||
|
||||
/// Builds the relay tags for setting the archived state of [channelId].
|
||||
List<List<String>> buildSetChannelArchivedTags(
|
||||
String channelId, {
|
||||
required bool archived,
|
||||
}) => [
|
||||
['h', channelId],
|
||||
['archived', archived.toString()],
|
||||
];
|
||||
|
||||
/// Builds the relay tags for deleting [channelId].
|
||||
List<List<String>> buildDeleteChannelTags(String channelId) => [
|
||||
['h', channelId],
|
||||
];
|
||||
|
||||
class ChannelActions {
|
||||
final Ref _ref;
|
||||
final RelaySessionNotifier _session;
|
||||
final SignedEventRelay _signedEventRelay;
|
||||
final String? _currentPubkey;
|
||||
final bool Function()? _isCommunityValid;
|
||||
|
||||
ChannelActions({
|
||||
required Ref ref,
|
||||
required RelaySessionNotifier session,
|
||||
required SignedEventRelay signedEventRelay,
|
||||
required String? currentPubkey,
|
||||
bool Function()? isCommunityValid,
|
||||
}) : _ref = ref,
|
||||
_session = session,
|
||||
_signedEventRelay = signedEventRelay,
|
||||
_currentPubkey = currentPubkey,
|
||||
_isCommunityValid = isCommunityValid;
|
||||
|
||||
Future<Channel> createChannel({
|
||||
required String name,
|
||||
required String channelType,
|
||||
required String visibility,
|
||||
String? description,
|
||||
int? ttlSeconds,
|
||||
}) async {
|
||||
final channelId = _newUuidV4();
|
||||
final tags = buildCreateChannelTags(
|
||||
channelId: channelId,
|
||||
name: name,
|
||||
channelType: channelType,
|
||||
visibility: visibility,
|
||||
description: description,
|
||||
ttlSeconds: ttlSeconds,
|
||||
);
|
||||
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
|
||||
return _refreshChannelsAndRead(channelId);
|
||||
}
|
||||
|
||||
/// Open (or create) a DM channel with the given pubkeys.
|
||||
///
|
||||
/// This submits a kind:41010 command event; the relay responds with an OK
|
||||
/// message whose content carries `response:{...}` containing the new
|
||||
/// `channel_id`.
|
||||
Future<Channel> openDm({required List<String> pubkeys}) async {
|
||||
final result = await _signedEventRelay.submit(
|
||||
kind: 41010,
|
||||
content: '',
|
||||
tags: pubkeys.map((pk) => ['p', pk]).toList(),
|
||||
);
|
||||
final response = parseCommandResponse(result.content);
|
||||
final channelId = response?['channel_id'] as String?;
|
||||
if (channelId == null || channelId.isEmpty) {
|
||||
throw Exception('Relay did not return a DM channel id');
|
||||
}
|
||||
return _refreshChannelsAndRead(channelId);
|
||||
}
|
||||
|
||||
Future<void> addMembers({
|
||||
required String channelId,
|
||||
required List<String> pubkeys,
|
||||
String role = 'member',
|
||||
}) async {
|
||||
final normalizedRole = role.trim();
|
||||
if (normalizedRole.isEmpty) {
|
||||
throw ArgumentError.value(role, 'role', 'must not be empty');
|
||||
}
|
||||
final normalizedPubkeys = {
|
||||
for (final pubkey in pubkeys)
|
||||
if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(),
|
||||
};
|
||||
_ensureCommunityValid();
|
||||
// Per-pubkey failures are collected rather than thrown on the spot: one
|
||||
// relay rejection must not skip the remaining adds or the invalidation
|
||||
// below, which would leave the members list stale for the adds that landed.
|
||||
final failures = <String, String>{};
|
||||
for (final pubkey in normalizedPubkeys) {
|
||||
// Outside the catch: a community switch mid-loop must abort the whole
|
||||
// add, not be recorded as this pubkey's rejection.
|
||||
_ensureCommunityValid();
|
||||
try {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9000,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey],
|
||||
['role', normalizedRole],
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
failures[pubkey] = _relayErrorMessage(error);
|
||||
}
|
||||
}
|
||||
_ensureCommunityValid();
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
if (failures.isNotEmpty) {
|
||||
throw AddMembersException(failures);
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureCommunityValid() {
|
||||
if (_isCommunityValid?.call() == false) {
|
||||
throw StateError(
|
||||
'Channel action cancelled because the active community changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> joinChannel(String channelId) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9021,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
],
|
||||
);
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
Future<void> leaveChannel(String channelId) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9022,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
],
|
||||
);
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
/// Archives the channel and refreshes its cached state.
|
||||
Future<void> archiveChannel(String channelId) =>
|
||||
_setChannelArchived(channelId, archived: true);
|
||||
|
||||
/// Unarchives the channel and refreshes its cached state.
|
||||
Future<void> unarchiveChannel(String channelId) =>
|
||||
_setChannelArchived(channelId, archived: false);
|
||||
|
||||
Future<void> _setChannelArchived(
|
||||
String channelId, {
|
||||
required bool archived,
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9002,
|
||||
content: '',
|
||||
tags: buildSetChannelArchivedTags(channelId, archived: archived),
|
||||
);
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
/// Deletes the channel and refreshes its cached state.
|
||||
Future<void> deleteChannel(String channelId) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9008,
|
||||
content: '',
|
||||
tags: buildDeleteChannelTags(channelId),
|
||||
);
|
||||
await _refreshChannelState(channelId);
|
||||
}
|
||||
|
||||
Future<void> setCanvas({
|
||||
required String channelId,
|
||||
required String content,
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 40100,
|
||||
content: content,
|
||||
tags: [
|
||||
['h', channelId],
|
||||
],
|
||||
);
|
||||
_ref.invalidate(channelCanvasProvider(channelId));
|
||||
}
|
||||
|
||||
/// User search via NIP-50 over kind:0 profile events.
|
||||
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
|
||||
final events = await _session.queryRelay([
|
||||
NostrFilters.searchUsers(trimmed, limit: limit),
|
||||
]);
|
||||
return directoryUsersFromProfileEvents(events)
|
||||
.where(
|
||||
(user) =>
|
||||
_currentPubkey == null ||
|
||||
user.pubkey.toLowerCase() != _currentPubkey,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Channel> _refreshChannelsAndRead(String channelId) async {
|
||||
await _ref.read(channelsProvider.notifier).refresh();
|
||||
final channels = await _ref.read(channelsProvider.future);
|
||||
return channels.firstWhere(
|
||||
(channel) => channel.id == channelId,
|
||||
orElse: () =>
|
||||
throw Exception('Channel was created but is not visible yet'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshChannelState(String channelId) async {
|
||||
await _ref.read(channelsProvider.notifier).refresh();
|
||||
_ref.invalidate(channelDetailsProvider(channelId));
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
_ref.invalidate(channelCanvasProvider(channelId));
|
||||
}
|
||||
|
||||
String _newUuidV4() {
|
||||
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
final hex = bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return '${hex.substring(0, 8)}-'
|
||||
'${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-'
|
||||
'${hex.substring(16, 20)}-'
|
||||
'${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
Future<void> changeMemberRole({
|
||||
required String channelId,
|
||||
required String pubkey,
|
||||
required String role,
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9000,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey.toLowerCase()],
|
||||
['role', role],
|
||||
],
|
||||
);
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> removeMember({
|
||||
required String channelId,
|
||||
required String pubkey,
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9001,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey.toLowerCase()],
|
||||
],
|
||||
);
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
_ref.invalidate(channelBotPubkeysProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> addReaction(String eventId, String emoji) async {
|
||||
final shortcode = normalizeShortcode(emoji);
|
||||
final emojiUrl = reactionEmojiUrl(
|
||||
emoji,
|
||||
_ref.read(customEmojiListProvider),
|
||||
);
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.reaction,
|
||||
content: emoji,
|
||||
tags: [
|
||||
['e', eventId],
|
||||
if (shortcode != null && emojiUrl != null)
|
||||
['emoji', shortcode, emojiUrl],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeReaction(String reactionEventId, String emoji) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.deletion,
|
||||
content: '',
|
||||
tags: [
|
||||
['e', reactionEventId],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> editMessage({
|
||||
required String channelId,
|
||||
required String eventId,
|
||||
required String content,
|
||||
List<List<String>> mediaTags = const [],
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.streamMessageEdit,
|
||||
content: content,
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['e', eventId],
|
||||
...mediaTags,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteMessage({
|
||||
required String channelId,
|
||||
required String eventId,
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.deletion,
|
||||
content: '',
|
||||
tags: buildDeleteMessageTags(channelId: channelId, eventId: eventId),
|
||||
);
|
||||
}
|
||||
|
||||
static final Random _random = Random.secure();
|
||||
}
|
||||
|
||||
final channelActionsProvider = Provider<ChannelActions>((ref) {
|
||||
final relayConfig = ref.watch(relayConfigProvider);
|
||||
final currentPubkey = ref.watch(currentPubkeyProvider);
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
return ChannelActions(
|
||||
ref: ref,
|
||||
session: session,
|
||||
signedEventRelay: SignedEventRelay(
|
||||
session: session,
|
||||
nsec: relayConfig.nsec,
|
||||
),
|
||||
currentPubkey: currentPubkey,
|
||||
isCommunityValid: () {
|
||||
final currentConfig = ref.read(relayConfigProvider);
|
||||
return currentConfig.baseUrl == relayConfig.baseUrl &&
|
||||
currentConfig.nsec == relayConfig.nsec;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
19
mobile/lib/features/channels/channel_metadata_updates.dart
Normal file
19
mobile/lib/features/channels/channel_metadata_updates.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
/// Builds the kind:9002 tags used to update channel name and description.
|
||||
List<List<String>> buildUpdateChannelTags({
|
||||
required String channelId,
|
||||
String? name,
|
||||
String? description,
|
||||
}) {
|
||||
if (name == null && description == null) {
|
||||
throw ArgumentError('At least one channel field must be provided');
|
||||
}
|
||||
final canonicalName = name?.trim().replaceFirst(RegExp(r'^#+'), '').trim();
|
||||
if (canonicalName != null && canonicalName.isEmpty) {
|
||||
throw ArgumentError.value(name, 'name', 'must not be empty');
|
||||
}
|
||||
return [
|
||||
['h', channelId],
|
||||
if (canonicalName != null) ['name', canonicalName],
|
||||
if (description != null) ['about', description.trim()],
|
||||
];
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import 'channel_typing_provider.dart';
|
||||
import 'small_avatar.dart';
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../profile/profile_avatar.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../profile/presence_cache_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../pairing/pairing_page.dart';
|
||||
import '../pairing/pairing_provider.dart';
|
||||
import 'channel.dart';
|
||||
@@ -166,7 +166,8 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
|
||||
final WidgetBuilder settingsPageBuilder;
|
||||
|
||||
/// Reports the Settings route's raw animation progress from 0 to 1.
|
||||
/// Reports Settings route progress so its foreground and Home's background
|
||||
/// render from the same timeline.
|
||||
final ValueChanged<double> onSettingsTransitionProgress;
|
||||
|
||||
/// Notifies this page when its already-selected tab is tapped again.
|
||||
@@ -346,12 +347,11 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
size: _kTopSectionAvatarSize,
|
||||
onTap: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
Navigator.of(context).push(
|
||||
_SettingsPageRoute(
|
||||
builder: settingsPageBuilder,
|
||||
onTransitionProgress: onSettingsTransitionProgress,
|
||||
),
|
||||
final route = _SettingsPageRoute(
|
||||
builder: settingsPageBuilder,
|
||||
onTransitionProgress: onSettingsTransitionProgress,
|
||||
);
|
||||
Navigator.of(context).push(route);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -389,23 +389,34 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
|
||||
builder(context),
|
||||
transitionsBuilder: _buildSettingsTransition,
|
||||
opaque: false,
|
||||
transitionDuration: const Duration(milliseconds: 190),
|
||||
allowSnapshotting: false,
|
||||
transitionDuration: const Duration(milliseconds: 220),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 190),
|
||||
);
|
||||
|
||||
final ValueChanged<double> onTransitionProgress;
|
||||
|
||||
Animation<double>? _progressAnimation;
|
||||
bool _hasStartedForwardTransition = false;
|
||||
|
||||
@override
|
||||
void install() {
|
||||
super.install();
|
||||
_progressAnimation = animation?..addListener(_reportProgress);
|
||||
_reportProgress();
|
||||
}
|
||||
|
||||
void _reportProgress() {
|
||||
onTransitionProgress(_progressAnimation?.value ?? 0);
|
||||
final progressAnimation = _progressAnimation;
|
||||
if (progressAnimation == null) return;
|
||||
|
||||
// ProxyAnimation briefly exposes the previous completed value while the
|
||||
// route installs its new controller. Ignore that handoff notification and
|
||||
// begin reporting only once the route is genuinely moving forward.
|
||||
if (!_hasStartedForwardTransition) {
|
||||
if (progressAnimation.status != AnimationStatus.forward) return;
|
||||
_hasStartedForwardTransition = true;
|
||||
}
|
||||
onTransitionProgress(progressAnimation.value);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -422,18 +433,22 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
|
||||
) {
|
||||
if (MediaQuery.disableAnimationsOf(context)) return child;
|
||||
|
||||
final incoming = CurvedAnimation(
|
||||
final motion = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
// Keep the complete page on one timeline. A gentler forward ease keeps
|
||||
// the entrance visible without letting scale finish ahead of opacity;
|
||||
// the existing reverse curve preserves the exit motion.
|
||||
curve: Curves.easeOutQuad,
|
||||
reverseCurve: Curves.easeOutCubic,
|
||||
);
|
||||
return FadeTransition(
|
||||
key: const ValueKey('settings-transition-opacity'),
|
||||
opacity: _SettingsOpacityAnimation(incoming),
|
||||
opacity: motion,
|
||||
child: RepaintBoundary(
|
||||
key: const ValueKey('settings-transition-layer'),
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 1.04, end: 1).animate(incoming),
|
||||
key: const ValueKey('settings-transition-scale'),
|
||||
scale: Tween<double>(begin: 1.04, end: 1).animate(motion),
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
@@ -441,22 +456,3 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps Settings already composed on entry while retaining a complete exit
|
||||
/// fade. Reading the parent live also keeps opacity synchronized with scale on
|
||||
/// the route's first frame.
|
||||
class _SettingsOpacityAnimation extends Animation<double>
|
||||
with AnimationWithParentMixin<double> {
|
||||
_SettingsOpacityAnimation(this.parent);
|
||||
|
||||
@override
|
||||
final Animation<double> parent;
|
||||
|
||||
@override
|
||||
double get value {
|
||||
final progress = parent.value;
|
||||
return parent.status == AnimationStatus.reverse
|
||||
? progress
|
||||
: 0.8 + (0.2 * progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
|
||||
import '../../shared/widgets/mobile_tab_footer_backdrop.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import '../../shared/deeplink/deep_link.dart';
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channel_mutes/channel_mutes_provider.dart';
|
||||
|
||||
class ManageChannelSheet extends HookConsumerWidget {
|
||||
final Channel channel;
|
||||
const ManageChannelSheet({
|
||||
super.key,
|
||||
required this.channel,
|
||||
this.canEditDetails = true,
|
||||
this.onChannelUpdated,
|
||||
});
|
||||
|
||||
const ManageChannelSheet({super.key, required this.channel});
|
||||
final Channel channel;
|
||||
final bool canEditDetails;
|
||||
final ValueChanged<Channel>? onChannelUpdated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final canvasAsync = ref.watch(channelCanvasProvider(channel.id));
|
||||
final nameController = useTextEditingController(text: channel.name);
|
||||
final descriptionController = useTextEditingController(
|
||||
text: channel.description,
|
||||
);
|
||||
useListenable(nameController);
|
||||
useListenable(descriptionController);
|
||||
final canvasController = useTextEditingController();
|
||||
final isEditingCanvas = useState(false);
|
||||
final isSavingCanvas = useState(false);
|
||||
final isBusy = useState(false);
|
||||
final isSavingDetails = useState(false);
|
||||
final actionError = useState<String?>(null);
|
||||
final canvasController = useTextEditingController();
|
||||
|
||||
useEffect(() {
|
||||
final canvas = canvasAsync.asData?.value;
|
||||
@@ -31,53 +42,47 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [canvasAsync.asData?.value.content, isEditingCanvas.value]);
|
||||
|
||||
final mutesState = ref.watch(channelMutesProvider);
|
||||
final isMuted = mutesState.store.channels[channel.id]?.muted == true;
|
||||
|
||||
final canJoin =
|
||||
channel.visibility == 'open' &&
|
||||
!channel.isArchived &&
|
||||
!channel.isMember &&
|
||||
!channel.isDm;
|
||||
final canLeave = channel.isMember && !channel.isArchived && !channel.isDm;
|
||||
final canonicalName = nameController.text
|
||||
.trim()
|
||||
.replaceFirst(RegExp(r'^#+'), '')
|
||||
.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
final nameDirty = canonicalName != channel.name.trim();
|
||||
final descriptionDirty = description != channel.description.trim();
|
||||
final canSaveDetails =
|
||||
canEditDetails &&
|
||||
canonicalName.isNotEmpty &&
|
||||
(nameDirty || descriptionDirty) &&
|
||||
!isSavingDetails.value;
|
||||
final canEditCanvas = channel.isMember && !channel.isArchived;
|
||||
|
||||
Future<void> joinChannel() async {
|
||||
if (isBusy.value) return;
|
||||
isBusy.value = true;
|
||||
Future<void> saveDetails() async {
|
||||
if (!canSaveDetails) return;
|
||||
isSavingDetails.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await ref.read(channelActionsProvider).joinChannel(channel.id);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.updateChannel(
|
||||
channelId: channel.id,
|
||||
name: nameDirty ? canonicalName : null,
|
||||
description: descriptionDirty ? description : null,
|
||||
);
|
||||
final updated = channel.copyWith(
|
||||
name: canonicalName,
|
||||
description: description,
|
||||
);
|
||||
onChannelUpdated?.call(updated);
|
||||
if (context.mounted) Navigator.of(context).pop(false);
|
||||
} catch (error) {
|
||||
actionError.value = error.toString();
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> leaveChannel() async {
|
||||
if (isBusy.value) return;
|
||||
isBusy.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await ref.read(channelActionsProvider).leaveChannel(channel.id);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} catch (error) {
|
||||
actionError.value = error.toString();
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
isSavingDetails.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveCanvas() async {
|
||||
if (isSavingCanvas.value) {
|
||||
return;
|
||||
}
|
||||
if (isSavingCanvas.value) return;
|
||||
isSavingCanvas.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
@@ -87,9 +92,7 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
channelId: channel.id,
|
||||
content: canvasController.text.trim(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
isEditingCanvas.value = false;
|
||||
}
|
||||
if (context.mounted) isEditingCanvas.value = false;
|
||||
} catch (error) {
|
||||
actionError.value = error.toString();
|
||||
} finally {
|
||||
@@ -109,99 +112,59 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Text(
|
||||
'Basic management for ${channel.name}.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
_ManageChannelTextField(
|
||||
fieldKey: const ValueKey('manage-channel-name'),
|
||||
outlineKey: const ValueKey('manage-channel-name-outline'),
|
||||
controller: nameController,
|
||||
enabled: canEditDetails && !isSavingDetails.value,
|
||||
hintText: 'Channel name',
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
if (actionError.value case final error?) ...[
|
||||
const SizedBox(height: Grid.xs),
|
||||
const SizedBox(height: Grid.xs),
|
||||
_ManageChannelTextField(
|
||||
fieldKey: const ValueKey('manage-channel-description'),
|
||||
outlineKey: const ValueKey('manage-channel-description-outline'),
|
||||
controller: descriptionController,
|
||||
enabled: canEditDetails && !isSavingDetails.value,
|
||||
hintText: 'Description',
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.newline,
|
||||
),
|
||||
if (canonicalName.isEmpty) ...[
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
error,
|
||||
'Channel name is required.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.xs),
|
||||
SwitchListTile(
|
||||
title: const Text('Mute channel'),
|
||||
subtitle: const Text('Suppress notifications and unread badges'),
|
||||
secondary: const Icon(LucideIcons.bellOff),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: isMuted,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
ref
|
||||
.read(channelMutesProvider.notifier)
|
||||
.muteChannel(channel.id);
|
||||
} else {
|
||||
ref
|
||||
.read(channelMutesProvider.notifier)
|
||||
.unmuteChannel(channel.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (canJoin || canLeave) ...[
|
||||
const SizedBox(height: Grid.xs),
|
||||
Wrap(
|
||||
spacing: Grid.xxs,
|
||||
children: [
|
||||
if (canJoin)
|
||||
FilledButton.tonal(
|
||||
onPressed: isBusy.value ? null : joinChannel,
|
||||
child: Text(
|
||||
isBusy.value ? 'Joining\u2026' : 'Join channel',
|
||||
),
|
||||
),
|
||||
if (canLeave)
|
||||
OutlinedButton(
|
||||
onPressed: isBusy.value ? null : leaveChannel,
|
||||
child: Text(
|
||||
isBusy.value ? 'Leaving\u2026' : 'Leave channel',
|
||||
),
|
||||
),
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('manage-channel-save-details'),
|
||||
onPressed: canSaveDetails ? saveDetails : null,
|
||||
child: Text(isSavingDetails.value ? 'Saving…' : 'Save changes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
Text('Context', style: context.textTheme.labelLarge),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
_ContextCard(
|
||||
label: 'Description',
|
||||
value: channel.description,
|
||||
emptyLabel: 'No description set',
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
_ContextCard(
|
||||
label: 'Topic',
|
||||
value: channel.topic,
|
||||
emptyLabel: 'No topic set',
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
_ContextCard(
|
||||
label: 'Purpose',
|
||||
value: channel.purpose,
|
||||
emptyLabel: 'No purpose set',
|
||||
),
|
||||
if (!channel.isDm) ...[
|
||||
const SizedBox(height: Grid.sm),
|
||||
Text('Canvas', style: context.textTheme.labelLarge),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
canvasAsync.when(
|
||||
_ManageChannelSection(
|
||||
label: 'Canvas',
|
||||
child: canvasAsync.when(
|
||||
data: (canvas) {
|
||||
if (isEditingCanvas.value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('manage-channel-canvas'),
|
||||
controller: canvasController,
|
||||
maxLines: 8,
|
||||
minLines: 6,
|
||||
decoration: const InputDecoration(
|
||||
hintText:
|
||||
'Write your canvas content in Markdown\u2026',
|
||||
hintText: 'Write your canvas content in Markdown…',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
@@ -225,7 +188,7 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
: saveCanvas,
|
||||
child: Text(
|
||||
isSavingCanvas.value
|
||||
? 'Saving\u2026'
|
||||
? 'Saving…'
|
||||
: 'Save canvas',
|
||||
),
|
||||
),
|
||||
@@ -236,22 +199,14 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
),
|
||||
child: Text(
|
||||
canvas.content?.trim().isNotEmpty == true
|
||||
? canvas.content!
|
||||
: 'No canvas set for this channel.',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
canvas.content?.trim().isNotEmpty == true
|
||||
? canvas.content!
|
||||
: 'No canvas set for this channel.',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
@@ -274,16 +229,25 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
loading: () => const Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 40,
|
||||
semanticLabel: 'Loading channel details',
|
||||
semanticLabel: 'Loading channel canvas',
|
||||
),
|
||||
),
|
||||
error: (error, _) => Text(
|
||||
error.toString(),
|
||||
'Couldn’t load the channel canvas.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (actionError.value case final error?) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
error,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -292,45 +256,94 @@ class ManageChannelSheet extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ContextCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String? value;
|
||||
final String emptyLabel;
|
||||
|
||||
const _ContextCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.emptyLabel,
|
||||
class _ManageChannelTextField extends StatelessWidget {
|
||||
const _ManageChannelTextField({
|
||||
required this.fieldKey,
|
||||
required this.outlineKey,
|
||||
required this.controller,
|
||||
required this.enabled,
|
||||
required this.hintText,
|
||||
required this.textInputAction,
|
||||
this.minLines = 1,
|
||||
this.maxLines = 1,
|
||||
});
|
||||
|
||||
final Key fieldKey;
|
||||
final Key outlineKey;
|
||||
final TextEditingController controller;
|
||||
final bool enabled;
|
||||
final String hintText;
|
||||
final TextInputAction textInputAction;
|
||||
final int minLines;
|
||||
final int maxLines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
return DecoratedBox(
|
||||
key: outlineKey,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(Radii.card),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
Text(
|
||||
value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
child: Semantics(
|
||||
label: hintText,
|
||||
textField: true,
|
||||
child: TextField(
|
||||
key: fieldKey,
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
style: context.textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.twelve,
|
||||
),
|
||||
),
|
||||
],
|
||||
textInputAction: textInputAction,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManageChannelSection extends StatelessWidget {
|
||||
const _ManageChannelSection({required this.label, required this.child});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
|
||||
child: Text(
|
||||
label,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.card),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(padding: const EdgeInsets.all(Grid.xs), child: child),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import '../profile/user_status.dart';
|
||||
import '../profile/user_status_cache_provider.dart';
|
||||
import 'agent_activity/agent_activity_sheet.dart';
|
||||
@@ -184,6 +184,7 @@ class _SectionLabel extends StatelessWidget {
|
||||
const _changeableRoles = ['admin', 'member', 'guest'];
|
||||
|
||||
String _roleLabel(String role) {
|
||||
if (role == 'bot') return 'Agent';
|
||||
if (role.isEmpty) return 'Member';
|
||||
return '${role[0].toUpperCase()}${role.substring(1)}';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import '../../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../profile/user_profile.dart';
|
||||
import '../../../shared/profile/user_profile.dart';
|
||||
import '../channel_management_provider.dart';
|
||||
import 'mention_ranking.dart';
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import '../../../shared/crypto/nip_oa.dart';
|
||||
import '../../../shared/mentions/agent_identity_provider.dart';
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../../profile/user_profile.dart';
|
||||
import '../../../shared/profile/user_cache_provider.dart';
|
||||
import '../../../shared/profile/user_profile.dart';
|
||||
import '../channel.dart';
|
||||
import '../channel_management_provider.dart';
|
||||
import '../channels_provider.dart';
|
||||
|
||||
@@ -11,8 +11,8 @@ import '../../shared/emoji/emoji_burst.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/emoji/positive_emoji.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'emoji_picker.dart';
|
||||
import 'recent_emoji_provider.dart';
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_messages_provider.dart';
|
||||
import 'message_mention_pubkeys.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
|
||||
/// 20px circle avatar used in thread summary rows and other compact lists.
|
||||
class SmallAvatar extends StatelessWidget {
|
||||
|
||||
@@ -13,8 +13,8 @@ import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
|
||||
import '../../shared/widgets/message_author_meta.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import 'android_ime_lift.dart';
|
||||
import 'channel_link_navigation.dart';
|
||||
import 'channel_messages_provider.dart';
|
||||
|
||||
@@ -9,9 +9,9 @@ import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import 'forum_models.dart';
|
||||
|
||||
/// Card displaying a forum post preview in the posts list.
|
||||
|
||||
@@ -15,8 +15,8 @@ import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../channels/compose_bar.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
import 'forum_models.dart';
|
||||
import 'forum_provider.dart';
|
||||
|
||||
@@ -126,6 +126,10 @@ class HomePage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<double>(
|
||||
key: const ValueKey('home-settings-transition-progress'),
|
||||
// Keep one stable listenable for Home. Swapping in the route's
|
||||
// animation can briefly rebuild with its completed value before the
|
||||
// new controller starts, which makes the background scale twice.
|
||||
valueListenable: settingsTransitionProgress,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -219,7 +223,10 @@ class HomePage extends HookConsumerWidget {
|
||||
: Curves.easeOutCubic.transform(progress);
|
||||
return Opacity(
|
||||
key: const ValueKey('home-settings-transition-opacity'),
|
||||
opacity: 1 - curvedProgress,
|
||||
// Settings supplies the crossfade. Keeping Home opaque beneath
|
||||
// it prevents the bare backdrop showing through two partially
|
||||
// transparent layers.
|
||||
opacity: 1,
|
||||
child: Transform.scale(
|
||||
key: const ValueKey('home-settings-transition-scale'),
|
||||
scale: lerpDouble(1, _settingsBackgroundScale, curvedProgress),
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/masked_avatar_badge.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'user_profile.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
|
||||
/// Matches desktop's sidebar profile card, whose avatar is 32px.
|
||||
const _defaultAvatarSize = 32.0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import 'user_profile.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
|
||||
/// The current user's profile (kind:0 metadata) loaded over the relay
|
||||
/// WebSocket. Returns null when no nsec is configured or when the user has
|
||||
|
||||
@@ -45,7 +45,8 @@ class SettingsProfileHeader extends HookConsumerWidget {
|
||||
showSetStatusSheet(context, currentStatus: status);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm),
|
||||
key: const ValueKey('settings-profile-header'),
|
||||
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.twelve),
|
||||
child: Column(
|
||||
children: [
|
||||
MaskedAvatarBadge(
|
||||
|
||||
@@ -19,7 +19,7 @@ import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import 'presence_cache_provider.dart';
|
||||
import 'user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import 'user_status_cache_provider.dart';
|
||||
|
||||
/// Show a user profile bottom sheet for the given [pubkey].
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import 'note_card.dart';
|
||||
import 'pulse_models.dart';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import 'note_card.dart';
|
||||
import 'pulse_actions.dart';
|
||||
import 'pulse_models.dart';
|
||||
|
||||
@@ -10,7 +10,7 @@ import '../../shared/widgets/avatar_image.dart';
|
||||
import '../channels/channel_detail_page.dart';
|
||||
import '../channels/channel_management_provider.dart';
|
||||
import '../channels/message_content.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile_sheet.dart';
|
||||
import 'compose_note_page.dart';
|
||||
import 'pulse_actions.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import 'pulse_models.dart';
|
||||
|
||||
final globalNotesProvider = FutureProvider<List<UserNote>>((ref) async {
|
||||
|
||||
@@ -23,8 +23,8 @@ import '../channels/message_content.dart';
|
||||
import '../channels/date_formatters.dart';
|
||||
import '../forum/forum_thread_page.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import '../../shared/profile/user_cache_provider.dart';
|
||||
import '../../shared/profile/user_profile.dart';
|
||||
import 'recent_searches_provider.dart';
|
||||
import 'search_provider.dart';
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class _AppearanceSection extends ConsumerWidget {
|
||||
|
||||
return AppListCard(
|
||||
label: 'Style · This community',
|
||||
verticalPadding: Grid.twelve,
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.sunMoon,
|
||||
|
||||
@@ -14,6 +14,7 @@ class _CommunitySection extends ConsumerWidget {
|
||||
|
||||
return AppListCard(
|
||||
label: 'Community',
|
||||
verticalPadding: Grid.twelve,
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.userPlus,
|
||||
|
||||
@@ -14,6 +14,7 @@ class _ConnectionSection extends ConsumerWidget {
|
||||
|
||||
return AppListCard(
|
||||
label: 'Connection',
|
||||
verticalPadding: Grid.twelve,
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.server,
|
||||
@@ -104,6 +105,7 @@ class _RemoveCommunitySection extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppListCard(
|
||||
verticalPadding: Grid.twelve,
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.logOut,
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/crypto/nip_oa.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../crypto/nip_oa.dart';
|
||||
import '../relay/relay.dart';
|
||||
import 'user_profile.dart';
|
||||
|
||||
/// In-memory cache of user profiles, fetched in batches from the relay.
|
||||
@@ -129,6 +129,7 @@ class AppListRowRaw extends StatelessWidget {
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.verticalPadding = _rowVerticalPadding,
|
||||
});
|
||||
|
||||
final Widget leading;
|
||||
@@ -136,13 +137,14 @@ class AppListRowRaw extends StatelessWidget {
|
||||
final Widget? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final double verticalPadding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final row = Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppListInset.of(context),
|
||||
vertical: _rowVerticalPadding,
|
||||
vertical: verticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -11,6 +11,7 @@ class AppListCard extends StatelessWidget {
|
||||
super.key,
|
||||
this.label,
|
||||
this.dividerIndent,
|
||||
this.verticalPadding = Grid.xxs,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@@ -22,6 +23,12 @@ class AppListCard extends StatelessWidget {
|
||||
/// separators align with their row content on both sides.
|
||||
final double? dividerIndent;
|
||||
|
||||
/// Outer padding above and below this section.
|
||||
///
|
||||
/// Adjacent cards contribute this padding from both sides. For example,
|
||||
/// passing [Grid.twelve] creates a 24dp rhythm between grouped surfaces.
|
||||
final double verticalPadding;
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
static const _inset = Grid.xs;
|
||||
@@ -53,11 +60,11 @@ class AppListCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xxs,
|
||||
verticalPadding,
|
||||
Grid.gutter,
|
||||
Grid.xxs,
|
||||
verticalPadding,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -40,42 +40,52 @@ class BuzzActionTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canTap = isEnabled && !isLoading;
|
||||
return GestureDetector(
|
||||
onTap: canTap
|
||||
? () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
}
|
||||
: null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Opacity(
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 68 + (Grid.xxs * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (isLoading)
|
||||
BuzzLoadingIndicator(
|
||||
size: 22,
|
||||
color: context.colors.onSurface,
|
||||
semanticLabel: loadingSemanticLabel ?? label,
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 22, color: context.colors.onSurface),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
void handleTap() {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
onTap();
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: canTap,
|
||||
label: isLoading ? loadingSemanticLabel ?? label : label,
|
||||
onTap: canTap ? handleTap : null,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: canTap ? handleTap : null,
|
||||
excludeFromSemantics: true,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Opacity(
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: 68 + (Grid.xxs * 2)),
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (isLoading)
|
||||
BuzzLoadingIndicator(
|
||||
size: 22,
|
||||
color: context.colors.onSurface,
|
||||
semanticLabel: loadingSemanticLabel ?? label,
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 22, color: context.colors.onSurface),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,8 +12,8 @@ import 'package:buzz/features/channels/channel_detail_page.dart';
|
||||
import 'package:buzz/features/channels/message_content.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/shared/read_state/read_state_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/anchored_popover_menu.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
|
||||
@@ -3,6 +3,9 @@ import 'dart:async';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_actions_sheet.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart';
|
||||
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
|
||||
import 'package:buzz/features/channels/manage_channel_sheet.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
@@ -12,7 +15,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
const _currentPubkey = 'me';
|
||||
|
||||
Channel _channel({String type = 'stream', bool isArchived = false}) => Channel(
|
||||
Channel _channel({
|
||||
String type = 'stream',
|
||||
bool isArchived = false,
|
||||
bool isMember = true,
|
||||
}) => Channel(
|
||||
id: 'channel-id',
|
||||
name: type == 'dm' ? 'Alice' : 'general',
|
||||
channelType: type,
|
||||
@@ -21,7 +28,7 @@ Channel _channel({String type = 'stream', bool isArchived = false}) => Channel(
|
||||
createdBy: 'owner',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 2,
|
||||
isMember: true,
|
||||
isMember: isMember,
|
||||
archivedAt: isArchived ? DateTime(2025, 1, 2) : null,
|
||||
);
|
||||
|
||||
@@ -58,6 +65,19 @@ Widget _modalApp({
|
||||
overrides: [
|
||||
currentPubkeyProvider.overrideWith((ref) => _currentPubkey),
|
||||
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
|
||||
channelSectionsProvider.overrideWith(
|
||||
() => _FakeChannelSectionsNotifier(const ChannelSectionStore()),
|
||||
),
|
||||
agentOwnersProvider.overrideWithValue(
|
||||
const AsyncValue.data(<String, String>{}),
|
||||
),
|
||||
channelCanvasProvider(channel.id).overrideWith(
|
||||
(ref) async => const ChannelCanvas(
|
||||
content: null,
|
||||
updatedAt: null,
|
||||
authorPubkey: null,
|
||||
),
|
||||
),
|
||||
channelActionsProvider.overrideWith(createChannelActions),
|
||||
],
|
||||
child: MaterialApp(
|
||||
@@ -284,6 +304,7 @@ void main() {
|
||||
|
||||
expect(find.text('Archive channel'), findsNothing);
|
||||
expect(find.text('Delete channel'), findsNothing);
|
||||
expect(find.text('Move to section…'), findsOneWidget);
|
||||
expect(find.text('Leave channel'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -302,29 +323,163 @@ void main() {
|
||||
expect(find.text('Channel actions unavailable'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'manage leave closes both nested sheets without popping the page',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
loadMembers: () async => const [],
|
||||
createChannelActions: (ref) => _FakeChannelActions(ref),
|
||||
testWidgets('Manage contains editing and canvas, not mute or leave', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
loadMembers: () async => const [],
|
||||
createChannelActions: (ref) => _FakeChannelActions(ref),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final manageSheet = find.byType(ManageChannelSheet);
|
||||
expect(manageSheet, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: manageSheet,
|
||||
matching: find.byKey(const ValueKey('manage-channel-name')),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: manageSheet, matching: find.text('Canvas')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: manageSheet, matching: find.text('Mute channel')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: manageSheet, matching: find.text('Leave channel')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: manageSheet, matching: find.text('Topic')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: manageSheet, matching: find.text('Purpose')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Manage refreshes metadata after saving from actions', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
loadMembers: () async => [
|
||||
ChannelMember(
|
||||
pubkey: _currentPubkey,
|
||||
role: 'owner',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
createChannelActions: (ref) => _FakeChannelActions(
|
||||
ref,
|
||||
onUpdateChannel: (channelId, name, description) async {},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Leave channel').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
'renamed',
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('manage-channel-save-details')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ChannelActionsSheet), findsNothing);
|
||||
expect(find.byType(Scaffold), findsOneWidget);
|
||||
},
|
||||
);
|
||||
expect(find.widgetWithText(ListTile, 'Manage channel'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
final nameField = tester.widget<TextField>(
|
||||
find.byKey(const ValueKey('manage-channel-name')),
|
||||
);
|
||||
expect(nameField.controller?.text, 'renamed');
|
||||
});
|
||||
|
||||
testWidgets('non-member cannot edit canvas from Manage channel', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(isMember: false),
|
||||
loadMembers: () async => const [],
|
||||
createChannelActions: (ref) => _FakeChannelActions(ref),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final editCanvas = find.widgetWithText(FilledButton, 'Create canvas');
|
||||
expect(editCanvas, findsOneWidget);
|
||||
expect(tester.widget<FilledButton>(editCanvas).onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('regular members can move channels but cannot edit metadata', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_modalApp(
|
||||
channel: _channel(),
|
||||
loadMembers: () async => [
|
||||
ChannelMember(
|
||||
pubkey: _currentPubkey,
|
||||
role: 'member',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
createChannelActions: (ref) => _FakeChannelActions(ref),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open actions'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Move to section…'), findsOneWidget);
|
||||
await tester.tap(find.text('Manage channel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final nameField = find.byKey(const ValueKey('manage-channel-name'));
|
||||
final descriptionField = find.byKey(
|
||||
const ValueKey('manage-channel-description'),
|
||||
);
|
||||
expect(tester.widget<TextField>(nameField).enabled, isFalse);
|
||||
expect(tester.widget<TextField>(descriptionField).enabled, isFalse);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.byKey(const ValueKey('manage-channel-save-details')),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Create canvas'),
|
||||
)
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('DM omits quick actions, then shows mute and copy rows', (
|
||||
tester,
|
||||
@@ -366,8 +521,25 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
|
||||
_FakeChannelSectionsNotifier(this._store);
|
||||
|
||||
final ChannelSectionStore _store;
|
||||
|
||||
@override
|
||||
ChannelSectionsState build() =>
|
||||
ChannelSectionsState(isReady: true, store: _store, version: 1);
|
||||
}
|
||||
|
||||
class _FakeChannelActions extends ChannelActions {
|
||||
_FakeChannelActions(Ref ref)
|
||||
final Future<void> Function(
|
||||
String channelId,
|
||||
String? name,
|
||||
String? description,
|
||||
)?
|
||||
onUpdateChannel;
|
||||
|
||||
_FakeChannelActions(Ref ref, {this.onUpdateChannel})
|
||||
: super(
|
||||
ref: ref,
|
||||
session: ref.read(relaySessionProvider.notifier),
|
||||
@@ -378,6 +550,15 @@ class _FakeChannelActions extends ChannelActions {
|
||||
currentPubkey: _currentPubkey,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateChannel({
|
||||
required String channelId,
|
||||
String? name,
|
||||
String? description,
|
||||
}) async {
|
||||
await onUpdateChannel?.call(channelId, name, description);
|
||||
}
|
||||
|
||||
String? unarchivedChannelId;
|
||||
|
||||
@override
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -201,6 +201,28 @@ void main() {
|
||||
});
|
||||
|
||||
group('build channel lifecycle tags', () {
|
||||
test('channel edits match desktop kind 9002 tags', () {
|
||||
expect(
|
||||
buildUpdateChannelTags(
|
||||
channelId: 'channel-id',
|
||||
name: ' ###general ',
|
||||
description: ' Team updates ',
|
||||
),
|
||||
[
|
||||
['h', 'channel-id'],
|
||||
['name', 'general'],
|
||||
['about', 'Team updates'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('channel edits reject a hash-only name', () {
|
||||
expect(
|
||||
() => buildUpdateChannelTags(channelId: 'channel-id', name: ' ### '),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('archive matches kind 9002 tags', () {
|
||||
expect(buildSetChannelArchivedTags('channel-id', archived: true), [
|
||||
['h', 'channel-id'],
|
||||
@@ -222,6 +244,49 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'stale channel actions cannot publish after a community switch',
|
||||
() async {
|
||||
final session = RelaySessionNotifier();
|
||||
final container = ProviderContainer(
|
||||
retry: (_, _) => null,
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final staleActions = container.read(channelActionsProvider);
|
||||
container
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(baseUrl: 'https://other-community.example', nsec: null);
|
||||
|
||||
final operations = <Future<void> Function()>[
|
||||
() =>
|
||||
staleActions.updateChannel(channelId: _channelId, name: 'renamed'),
|
||||
() => staleActions.archiveChannel(_channelId),
|
||||
() => staleActions.unarchiveChannel(_channelId),
|
||||
() => staleActions.deleteChannel(_channelId),
|
||||
() => staleActions.setCanvas(channelId: _channelId, content: 'canvas'),
|
||||
() => staleActions.changeMemberRole(
|
||||
channelId: _channelId,
|
||||
pubkey: _memberPubkey,
|
||||
role: 'admin',
|
||||
),
|
||||
() => staleActions.removeMember(
|
||||
channelId: _channelId,
|
||||
pubkey: _memberPubkey,
|
||||
),
|
||||
() => staleActions.leaveChannel(_channelId),
|
||||
];
|
||||
|
||||
for (final operation in operations) {
|
||||
await expectLater(operation(), throwsA(isA<StateError>()));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
group('channelMembersProvider', () {
|
||||
test('waits for the relay connection before fetching members', () async {
|
||||
final session = _ConnectionAwareRelaySession();
|
||||
@@ -447,6 +512,17 @@ const _channelId = '11111111-1111-4111-8111-111111111111';
|
||||
const _memberPubkey =
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
|
||||
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() =>
|
||||
RelayConfig(baseUrl: 'https://first-community.example', nsec: null);
|
||||
|
||||
@override
|
||||
void update({required String baseUrl, String? nsec}) {
|
||||
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionAwareRelaySession extends RelaySessionNotifier {
|
||||
int historyQueryCount = 0;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart';
|
||||
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
|
||||
import 'package:buzz/features/profile/profile_avatar.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/community/community_icon_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
@@ -744,9 +744,10 @@ void main() {
|
||||
final route = ModalRoute.of(tester.element(find.text('Injected settings')));
|
||||
expect(route, isNot(isA<MaterialPageRoute<void>>()));
|
||||
expect(route?.opaque, isFalse);
|
||||
expect(route?.allowSnapshotting, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('reports Settings progress in both directions', (tester) async {
|
||||
testWidgets('reports monotonic Settings route progress', (tester) async {
|
||||
final progress = <double>[];
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
@@ -759,23 +760,31 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
await tester.pump();
|
||||
expect(progress, [0]);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(progress.last, inExclusiveRange(0, 1));
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
expect(progress.last, inExclusiveRange(0, 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(progress.any((value) => value > 0 && value < 1), isTrue);
|
||||
expect(progress.last, 1);
|
||||
for (var index = 1; index < progress.length; index++) {
|
||||
expect(progress[index], greaterThanOrEqualTo(progress[index - 1]));
|
||||
}
|
||||
|
||||
final reverseStart = progress.length;
|
||||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||||
await tester.pump();
|
||||
final reverseStart = progress.length;
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
expect(progress.last, inExclusiveRange(0, 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
progress.skip(reverseStart).any((value) => value > 0 && value < 1),
|
||||
isTrue,
|
||||
);
|
||||
expect(progress.last, 0);
|
||||
for (var index = reverseStart + 1; index < progress.length; index++) {
|
||||
expect(progress[index], lessThanOrEqualTo(progress[index - 1]));
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('paints Settings content with its surface from the first frame', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('scales and fully fades Settings into view', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
@@ -792,7 +801,12 @@ void main() {
|
||||
const ValueKey('settings-transition-opacity'),
|
||||
skipOffstage: false,
|
||||
);
|
||||
final scaleTransition = find.byKey(
|
||||
const ValueKey('settings-transition-scale'),
|
||||
skipOffstage: false,
|
||||
);
|
||||
expect(transition, findsOneWidget);
|
||||
expect(scaleTransition, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: transition,
|
||||
@@ -803,24 +817,44 @@ void main() {
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<FadeTransition>(transition).opacity.value, 0.8);
|
||||
expect(tester.widget<FadeTransition>(transition).opacity.value, 0);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
final forwardOpacity = tester
|
||||
.widget<FadeTransition>(transition)
|
||||
.opacity
|
||||
.value;
|
||||
final forwardScale = tester
|
||||
.widget<ScaleTransition>(scaleTransition)
|
||||
.scale
|
||||
.value;
|
||||
final forwardScaleProgress = (1.04 - forwardScale) / 0.04;
|
||||
expect(
|
||||
tester.widget<FadeTransition>(transition).opacity.value,
|
||||
inExclusiveRange(0.8, 1),
|
||||
forwardOpacity,
|
||||
closeTo(Curves.easeOutQuad.transform(95 / 220), 0.02),
|
||||
);
|
||||
expect(forwardScaleProgress, closeTo(forwardOpacity, 0.001));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<FadeTransition>(transition).opacity.value, 1);
|
||||
|
||||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
final reverseOpacity = tester
|
||||
.widget<FadeTransition>(transition)
|
||||
.opacity
|
||||
.value;
|
||||
final reverseScale = tester
|
||||
.widget<ScaleTransition>(scaleTransition)
|
||||
.scale
|
||||
.value;
|
||||
final reverseScaleProgress = (1.04 - reverseScale) / 0.04;
|
||||
expect(
|
||||
tester.widget<FadeTransition>(transition).opacity.value,
|
||||
reverseOpacity,
|
||||
inExclusiveRange(0, 1),
|
||||
reason: 'The complete Settings layer still fades out on exit.',
|
||||
);
|
||||
expect(reverseScaleProgress, closeTo(reverseOpacity, 0.001));
|
||||
});
|
||||
|
||||
testWidgets('gives feedback for the profile and community controls', (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
|
||||
final userPubkey = 'a' * 64;
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:buzz/features/channels/timeline_message.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_burst.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_data.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:buzz/features/forum/forum_posts_view.dart';
|
||||
import 'package:buzz/features/forum/forum_provider.dart';
|
||||
import 'package:buzz/features/forum/forum_thread_page.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:buzz/features/home/home_page.dart';
|
||||
import 'package:buzz/features/channels/channels_page.dart';
|
||||
import 'package:buzz/features/profile/profile_avatar.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -111,6 +112,90 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps Home opaque beneath the Settings transition', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildHome());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
|
||||
double homeOpacity() => tester
|
||||
.widget<Opacity>(
|
||||
find.byKey(const ValueKey('home-settings-transition-opacity')),
|
||||
)
|
||||
.opacity;
|
||||
|
||||
expect(homeOpacity(), 1);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
Navigator.of(
|
||||
tester.element(
|
||||
find.byKey(
|
||||
const ValueKey('settings-transition-opacity'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
),
|
||||
).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
|
||||
expect(homeOpacity(), 1);
|
||||
});
|
||||
|
||||
testWidgets('uses one monotonic route animation for Settings and Home', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildHome());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
double homeScale() => tester
|
||||
.widget<Transform>(
|
||||
find.byKey(const ValueKey('home-settings-transition-scale')),
|
||||
)
|
||||
.transform
|
||||
.storage[0];
|
||||
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
await tester.pump();
|
||||
|
||||
final settingsTransition = find.byKey(
|
||||
const ValueKey('settings-transition-opacity'),
|
||||
skipOffstage: false,
|
||||
);
|
||||
final settingsRoute = ModalRoute.of(tester.element(settingsTransition));
|
||||
|
||||
final entranceScales = <double>[homeScale()];
|
||||
final routeValues = <double>[settingsRoute!.animation!.value];
|
||||
for (var frame = 0; frame < 15; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
entranceScales.add(homeScale());
|
||||
routeValues.add(settingsRoute.animation!.value);
|
||||
}
|
||||
expect(entranceScales.first, closeTo(1, 0.000001));
|
||||
final reversalFrames = <int>[];
|
||||
for (var frame = 1; frame < entranceScales.length; frame++) {
|
||||
if (entranceScales[frame] > entranceScales[frame - 1] + 0.000001) {
|
||||
reversalFrames.add(frame);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
reversalFrames,
|
||||
isEmpty,
|
||||
reason:
|
||||
'Home must scale down in one direction on entrance. '
|
||||
'scales=$entranceScales route=$routeValues',
|
||||
);
|
||||
expect(entranceScales, everyElement(inInclusiveRange(0.97, 1)));
|
||||
expect(entranceScales.last, closeTo(0.97, 0.001));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
Navigator.of(tester.element(settingsTransition)).pop();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('gives selection haptics only when the tab changes', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/profile/profile_avatar.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||||
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/settings_profile_header.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/features/profile/user_status.dart';
|
||||
import 'package:buzz/features/profile/user_status_provider.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||
@@ -20,6 +20,26 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('reserves half of the 24dp settings section gap', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
profileProvider.overrideWith(_FakeProfileNotifier.new),
|
||||
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
|
||||
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
|
||||
customEmojiListProvider.overrideWithValue(const []),
|
||||
],
|
||||
child: const SettingsProfileHeader(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final header = tester.widget<Padding>(
|
||||
find.byKey(const ValueKey('settings-profile-header')),
|
||||
);
|
||||
expect((header.padding as EdgeInsets).bottom, Grid.twelve);
|
||||
});
|
||||
|
||||
testWidgets('shows the poster until the animated avatar is ready', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/features/pulse/compose_note_page.dart';
|
||||
import 'package:buzz/features/pulse/pulse_models.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/features/pulse/note_card.dart';
|
||||
import 'package:buzz/features/pulse/pulse_models.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/features/channels/message_content.dart';
|
||||
import 'package:buzz/features/channels/small_avatar.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/features/search/recent_searches_provider.dart';
|
||||
import 'package:buzz/features/search/search_page.dart';
|
||||
import 'package:buzz/features/search/search_provider.dart';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:buzz/features/settings/settings_page.dart';
|
||||
import 'package:buzz/shared/community/community_membership_provider.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/app_list_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -106,4 +107,41 @@ void main() {
|
||||
|
||||
expect(find.text('Invite to community'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('uses a 24dp rhythm between profile settings groups', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
currentCommunityRoleProvider.overrideWithValue(
|
||||
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: SettingsPage(
|
||||
profileHeader: const SizedBox(height: 100),
|
||||
invitePageBuilder: (_) => const SizedBox.shrink(),
|
||||
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final sections = tester.widgetList<AppListCard>(find.byType(AppListCard));
|
||||
expect(sections, isNotEmpty);
|
||||
expect(
|
||||
sections.every((section) => section.verticalPadding == Grid.twelve),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user