Merge remote-tracking branch 'origin/main' into projects-design-v3-layout-test

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-13 13:03:00 -04:00
33 changed files with 3253 additions and 290 deletions

View File

@@ -9,6 +9,7 @@ import 'features/activity/inbox_local_state_provider.dart';
import 'features/activity/inbox_read_state.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/invites/invite_create_page.dart';
import 'features/pairing/pairing_page.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
import 'features/channels/deep_link_dispatcher.dart';
@@ -149,6 +150,7 @@ class App extends HookConsumerWidget {
Widget _buildSettingsPage(BuildContext context) => SettingsPage(
profileHeader: const SettingsProfileHeader(),
invitePageBuilder: (_) => const CommunityInvitePage(),
identityRecoveryPageBuilder: (_) =>
const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
);

View File

@@ -68,6 +68,68 @@ class ChannelMember {
}
}
String _channelMemberSnapshotKey({
required String relayBaseUrl,
required String? pubkey,
required String channelId,
}) =>
'${relayBaseUrl.toLowerCase()}::${pubkey?.toLowerCase() ?? 'anon'}::$channelId';
class _ChannelMembersSnapshotCache {
final _membersByKey = <String, List<ChannelMember>>{};
List<ChannelMember>? read({
required String relayBaseUrl,
required String? pubkey,
required String channelId,
}) =>
_membersByKey[_channelMemberSnapshotKey(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
)];
void write({
required String relayBaseUrl,
required String? pubkey,
required String channelId,
required List<ChannelMember> members,
}) {
_membersByKey[_channelMemberSnapshotKey(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
)] = List.unmodifiable(
members,
);
}
}
final _channelMembersSnapshotCacheProvider =
Provider<_ChannelMembersSnapshotCache>((ref) {
return _ChannelMembersSnapshotCache();
});
/// Uses the relay-backed member list when connected, but keeps the channel
/// snapshot visible while a reconnect temporarily interrupts the refresh.
///
/// The provider retains its current value while disconnected and also keeps a
/// relay/account/channel-scoped snapshot for consumers that mount during the
/// reconnect window. An empty member list is authoritative only after a
/// connected fetch completes.
List<ChannelMember> channelMembersForAutocomplete({
required AsyncValue<List<ChannelMember>> membersAsync,
required SessionStatus sessionStatus,
required List<ChannelMember> cachedMembers,
}) {
final loadedMembers = membersAsync.asData?.value;
if (loadedMembers == null) return cachedMembers;
if (sessionStatus != SessionStatus.connected && loadedMembers.isEmpty) {
return cachedMembers;
}
return loadedMembers;
}
@immutable
class ChannelCanvas {
final String? content;
@@ -421,17 +483,59 @@ final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
final channelMembersProvider = FutureProvider.autoDispose
.family<List<ChannelMember>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
final session = ref.watch(relaySessionProvider.notifier);
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
final snapshotCache = ref.read(_channelMembersSnapshotCacheProvider);
// Re-fetch only after reconnect completes. During the disconnected
// interval this provider has no session dependency, so its current value
// remains visible to every consumer rather than becoming AsyncData([]).
ref.listen(relaySessionProvider, (previous, next) {
if (next.status == SessionStatus.connected &&
previous?.status != SessionStatus.connected) {
ref.invalidateSelf();
}
});
final sessionState = ref.read(relaySessionProvider);
if (sessionState.status != SessionStatus.connected) {
final cachedMembers = snapshotCache.read(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
);
if (cachedMembers != null) return cachedMembers;
final channelListMembers = ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId);
if (channelListMembers.isNotEmpty) {
snapshotCache.write(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
members: channelListMembers,
);
return channelListMembers;
}
return const [];
}
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.channelMembers(channelId),
);
if (events.isEmpty) return const [];
if (events.isEmpty) {
snapshotCache.write(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
members: const [],
);
return const [];
}
final event = events.first;
final joinedAt = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
return membersFromEvent(event)
final members = membersFromEvent(event)
.map(
(m) => ChannelMember(
pubkey: m.pubkey,
@@ -440,6 +544,13 @@ final channelMembersProvider = FutureProvider.autoDispose
),
)
.toList();
snapshotCache.write(
relayBaseUrl: relayBaseUrl,
pubkey: pubkey,
channelId: channelId,
members: members,
);
return members;
});
/// Channel canvas (kind:40100 for the channel).

View File

@@ -9,7 +9,8 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
import 'channel_management_provider.dart' show channelDetailsProvider;
import 'channel_management_provider.dart'
show ChannelMember, channelDetailsProvider;
import 'channel_mutes/channel_mutes_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'thread_follows/thread_follows_provider.dart';
@@ -49,6 +50,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Set<String> _authoredRootIds = {};
String? _threadInterestPubkey;
bool _hasLoaded = false;
String? _memberSnapshotRelayBaseUrl;
String? _memberSnapshotPubkey;
Map<String, List<ChannelMember>> _memberSnapshotsByChannelId = const {};
/// The member snapshot already returned while loading the channel list.
///
/// Mention autocomplete can use this synchronously while its independent
/// channel-member refresh is still in flight.
List<ChannelMember> cachedMembersForChannel(String channelId) =>
_memberSnapshotsByChannelId[channelId] ?? const [];
Map<String, int> get latestObservedByChannel =>
Map.unmodifiable(_latestObservedByChannel);
@@ -62,7 +73,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
@override
Future<List<Channel>> build() async {
ref.watch(relayConfigProvider);
final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl;
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
if (_memberSnapshotRelayBaseUrl != relayBaseUrl ||
_memberSnapshotPubkey != pubkey) {
_memberSnapshotRelayBaseUrl = relayBaseUrl;
_memberSnapshotPubkey = pubkey;
_memberSnapshotsByChannelId = const {};
}
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
@@ -151,6 +169,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
.whereType<String>()
.toSet()
.toList();
_cacheMemberSnapshots(memberships, replaceAll: true);
if (channelIds.isEmpty) {
if (subscribeLive) await _subscribeLive(const []);
return const [];
@@ -234,6 +253,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
limit: channelIds.length,
),
);
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
final memberCounts = <String, int>{};
for (final event in memberEvents) {
final chId = event.getTagValue('d');
@@ -356,6 +376,40 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
return channels;
}
void _cacheMemberSnapshots(
Iterable<NostrEvent> events, {
bool replaceAll = false,
}) {
final latestByChannelId = <String, NostrEvent>{};
for (final event in events) {
final channelId = event.getTagValue('d');
if (channelId == null) continue;
final current = latestByChannelId[channelId];
if (current == null || event.createdAt > current.createdAt) {
latestByChannelId[channelId] = event;
}
}
final snapshots = replaceAll
? <String, List<ChannelMember>>{}
: Map<String, List<ChannelMember>>.of(_memberSnapshotsByChannelId);
snapshots.addAll({
for (final entry in latestByChannelId.entries)
entry.key: List.unmodifiable([
for (final member in membersFromEvent(entry.value))
ChannelMember(
pubkey: member.pubkey,
role: member.role,
joinedAt: DateTime.fromMillisecondsSinceEpoch(
entry.value.createdAt * 1000,
isUtc: true,
),
),
]),
});
_memberSnapshotsByChannelId = Map.unmodifiable(snapshots);
}
Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,

View File

@@ -31,7 +31,10 @@ class ComposeBar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useMemoized(_MarkdownEditingController.new);
useListenable(controller);
final composerText = useListenableSelector(
controller,
() => controller.text,
);
useEffect(() => controller.dispose, [controller]);
// Restore and persist unsent text as a local draft so the Activity
// inbox Drafts filter reflects real composer state.
@@ -198,6 +201,12 @@ class ComposeBar extends HookConsumerWidget {
final channelsAsync = ref.watch(channelsProvider);
final membersAsync = ref.watch(channelMembersProvider(channelId));
final sessionStatus = ref.watch(relaySessionProvider).status;
final cachedMembers = channelsAsync.asData == null
? const <ChannelMember>[]
: ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId);
final currentPubkey = ref.watch(currentPubkeyProvider);
final userCache = ref.watch(userCacheProvider);
final isDmChannel =
@@ -220,7 +229,11 @@ class ComposeBar extends HookConsumerWidget {
}, [controller, agentMentionLabelsKey]);
useEffect(
() {
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
final memberList = channelMembersForAutocomplete(
membersAsync: membersAsync,
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
);
final pubkeys = [
...memberList.map((m) => m.pubkey),
...?relayAgents?.map((a) => a.pubkey),
@@ -233,6 +246,7 @@ class ComposeBar extends HookConsumerWidget {
},
[
membersAsync.asData?.value.length,
cachedMembers.length,
relayAgents?.length,
agentOwners?.length,
],
@@ -241,16 +255,22 @@ class ComposeBar extends HookConsumerWidget {
// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
final isModifyingText = useRef(false);
final lastObservedEditingValue = useRef(controller.value);
// Detect @mention query and broadcast typing on text / selection change.
useEffect(() {
lastObservedEditingValue.value = controller.value;
void listener() {
if (isModifyingText.value) return;
final text = controller.text;
final sel = controller.selection;
final editingValue = controller.value;
final previousValue = lastObservedEditingValue.value;
lastObservedEditingValue.value = editingValue;
if (isModifyingText.value || editingValue == previousValue) return;
final text = editingValue.text;
final sel = editingValue.selection;
final textChanged = text != previousValue.text;
// Broadcast typing indicator (throttled).
if (text.isNotEmpty) {
if (textChanged && text.isNotEmpty) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastTypingSentMs.value > _typingThrottleMs) {
lastTypingSentMs.value = now;
@@ -341,24 +361,34 @@ class ComposeBar extends HookConsumerWidget {
mentionMap.value[name] = candidate;
final start = mentionStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
isModifyingText.value = true;
try {
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
} finally {
isModifyingText.value = false;
}
mentionQuery.value = null;
}
// Insert a selected channel into the text field.
void insertChannel(Channel channel) {
final start = channelStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
isModifyingText.value = true;
try {
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
} finally {
isModifyingText.value = false;
}
channelQuery.value = null;
}
@@ -560,7 +590,7 @@ class ComposeBar extends HookConsumerWidget {
}
}
void queueAttachment(
final queueAttachment = useCallback((
XFile file,
_PendingAttachmentKind kind, {
bool deleteAfterUse = false,
@@ -575,7 +605,7 @@ class ComposeBar extends HookConsumerWidget {
deleteAfterUse: deleteAfterUse,
),
];
}
}, [draftRevision, uploadError, attachments]);
Future<void> pickThenQueue({
required Future<XFile?> Function() pick,
@@ -611,28 +641,28 @@ class ComposeBar extends HookConsumerWidget {
Future<void> retainAndQueueImages(List<XFile> images) =>
_retainAndQueueImages(context, images, queueImages);
Widget buildContextMenu(
BuildContext context,
EditableTextState editableTextState,
) {
void pasteImage() {
ContextMenuController.removeAny();
unawaited(() async {
try {
final image = await ref
.read(mediaUploadServiceProvider)
.readClipboardImage();
if (image != null && context.mounted) {
queueAttachment(image, _PendingAttachmentKind.image);
} else if (context.mounted) {
uploadError.value = 'Unable to read pasted image';
}
} catch (error) {
if (context.mounted) uploadError.value = _formatUploadError(error);
final pasteClipboardImage = useCallback(() {
ContextMenuController.removeAny();
unawaited(() async {
try {
final image = await ref
.read(mediaUploadServiceProvider)
.readClipboardImage();
if (image != null && context.mounted) {
queueAttachment(image, _PendingAttachmentKind.image);
} else if (context.mounted) {
uploadError.value = 'Unable to read pasted image';
}
}());
}
} catch (error) {
if (context.mounted) uploadError.value = _formatUploadError(error);
}
}());
}, [context, ref, queueAttachment, uploadError]);
final buildContextMenu = useCallback<EditableTextContextMenuBuilder>((
context,
editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS &&
SystemContextMenu.isSupportedByField(editableTextState)) {
return SystemContextMenu.editableText(
@@ -641,7 +671,7 @@ class ComposeBar extends HookConsumerWidget {
if (clipboardHasImage.value)
IOSSystemContextMenuItemCustom(
title: 'Paste Image',
onPressed: pasteImage,
onPressed: pasteClipboardImage,
),
...SystemContextMenu.getDefaultItems(editableTextState),
],
@@ -653,14 +683,17 @@ class ComposeBar extends HookConsumerWidget {
clipboardHasImage.value) {
buttonItems.insert(
0,
ContextMenuButtonItem(label: 'Paste Image', onPressed: pasteImage),
ContextMenuButtonItem(
label: 'Paste Image',
onPressed: pasteClipboardImage,
),
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
}, [clipboardHasImage, pasteClipboardImage]);
void uploadPastedImage(KeyboardInsertedContent content) {
final bytes = content.data;
@@ -791,6 +824,9 @@ class ComposeBar extends HookConsumerWidget {
? 320
: 250,
);
final resizeDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 140);
final suggestionOverlayController = useMemoized(
OverlayPortalController.new,
);
@@ -923,6 +959,7 @@ class ComposeBar extends HookConsumerWidget {
formattingOpen: showFormatting.value,
onCloseFormatting: () => showFormatting.value = false,
motionDuration: motionDuration,
resizeDuration: resizeDuration,
onFormat: applyFormat,
onMention: () {
attachmentSurface.value = _AttachmentSurface.closed;
@@ -946,7 +983,7 @@ class ComposeBar extends HookConsumerWidget {
showFormatting.value = true;
},
hasPendingUploads: hasPendingUploads,
canSend: controller.text.trim().isNotEmpty || hasAttachments,
canSend: composerText.trim().isNotEmpty || hasAttachments,
isSending: isSending.value,
),
),

View File

@@ -123,13 +123,7 @@ class _ComposerOverlayPortal extends StatelessWidget {
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: surface == _AttachmentSurface.closed
? _SuggestionPanelMotion(
duration: surfaceDuration,
alignment: Alignment.bottomLeft,
child: buildOverlayPanel(surface),
)
: buildOverlayPanel(surface),
child: buildOverlayPanel(surface),
),
),
),

View File

@@ -42,7 +42,11 @@ void _useComposeDraftLifecycle({
controller.text = saved;
}
var lastPersistedText = controller.text;
void persistDraft() {
final text = controller.text;
if (text == lastPersistedText) return;
lastPersistedText = text;
draftRevision.value += 1;
ref
.read(composeDraftsProvider.notifier)
@@ -50,7 +54,7 @@ void _useComposeDraftLifecycle({
key: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
text: controller.text,
text: text,
);
}

View File

@@ -100,9 +100,9 @@ void spliceAndMoveCursor(
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
controller.value = TextEditingValue(
text: '$before$replacement$after',
selection: TextSelection.collapsed(offset: start + replacement.length),
);
focusNode.requestFocus();
}
@@ -124,9 +124,9 @@ void _insertTriggerAtCursor(
final insert = needsSpace ? ' $trigger' : trigger;
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
controller.value = TextEditingValue(
text: '$before$insert$after',
selection: TextSelection.collapsed(offset: cursor + insert.length),
);
focusNode.requestFocus();
}

View File

@@ -19,6 +19,7 @@ class _ComposeBarLayout extends StatelessWidget {
final bool formattingOpen;
final VoidCallback onCloseFormatting;
final Duration motionDuration;
final Duration resizeDuration;
final void Function(String prefix, [String? suffix]) onFormat;
final VoidCallback onMention;
final VoidCallback onChannel;
@@ -47,6 +48,7 @@ class _ComposeBarLayout extends StatelessWidget {
required this.formattingOpen,
required this.onCloseFormatting,
required this.motionDuration,
required this.resizeDuration,
required this.onFormat,
required this.onMention,
required this.onChannel,
@@ -105,34 +107,18 @@ class _ComposeBarLayout extends StatelessWidget {
// Keep the default state out of the focus system entirely so
// restored native focus cannot expand a newly opened channel.
if (isExpanded)
TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.send,
contextMenuBuilder: contextMenuBuilder,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: onContentInserted,
),
onSubmitted: (_) => onSend(),
minLines: 1,
maxLines: 5,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: resolvedHint,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.half,
),
isDense: true,
),
)
resizeDuration == Duration.zero
? KeyedSubtree(
key: const ValueKey('composer-text-height-motion'),
child: _buildTextField(context),
)
: AnimatedSize(
key: const ValueKey('composer-text-height-motion'),
alignment: Alignment.topCenter,
duration: resizeDuration,
curve: Curves.easeOutCubic,
child: _buildTextField(context),
)
else
Row(
children: [
@@ -263,6 +249,43 @@ class _ComposeBarLayout extends StatelessWidget {
),
);
}
Widget _buildTextField(BuildContext context) {
return TextField(
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
contextMenuBuilder: contextMenuBuilder,
// Flutter's Cupertino magnifier rebuilds its overlay on every
// selection-handle update. Keep the iOS handles and native edit menu,
// but let the handles track the finger directly here.
magnifierConfiguration: defaultTargetPlatform == TargetPlatform.iOS
? TextMagnifierConfiguration.disabled
: null,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: onContentInserted,
),
minLines: 1,
maxLines: 5,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: resolvedHint,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.half,
),
isDense: true,
),
);
}
}
/// Drag the compose bar downward to put the keyboard away.

View File

@@ -16,6 +16,12 @@ class _MarkdownRule {
class _MarkdownEditingController extends TextEditingController {
final Set<String> _agentMentionNames = <String>{};
TextSpan? _cachedTextSpan;
String? _cachedText;
TextRange? _cachedComposingRange;
TextStyle? _cachedBaseStyle;
Color? _cachedOnSurface;
Color? _cachedSurface;
static final _rules = [
_MarkdownRule(
@@ -45,6 +51,7 @@ class _MarkdownEditingController extends TextEditingController {
_agentMentionNames
..clear()
..addAll(next);
_cachedTextSpan = null;
notifyListeners();
}
@@ -59,7 +66,16 @@ class _MarkdownEditingController extends TextEditingController {
withComposing && value.composing.isValid && !value.composing.isCollapsed
? value.composing
: TextRange.empty;
return TextSpan(
final colors = context.colors;
if (_cachedTextSpan case final cached?
when _cachedText == text &&
_cachedComposingRange == composingRange &&
_cachedBaseStyle == baseStyle &&
_cachedOnSurface == colors.onSurface &&
_cachedSurface == colors.surface) {
return cached;
}
final span = TextSpan(
style: baseStyle,
children: _buildMarkdownSpans(
context,
@@ -69,6 +85,13 @@ class _MarkdownEditingController extends TextEditingController {
composingRange: composingRange,
),
);
_cachedText = text;
_cachedComposingRange = composingRange;
_cachedBaseStyle = baseStyle;
_cachedOnSurface = colors.onSurface;
_cachedSurface = colors.surface;
_cachedTextSpan = span;
return span;
}
List<InlineSpan> _buildMarkdownSpans(

View File

@@ -1,99 +1,5 @@
part of '../compose_bar.dart';
class _SuggestionPanelMotion extends HookWidget {
final Duration duration;
final Alignment alignment;
final Widget child;
const _SuggestionPanelMotion({
required this.duration,
required this.alignment,
required this.child,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final springController = useAnimationController(
initialValue: 1,
upperBound: 1.08,
);
final springValue = useAnimation(springController);
final previousChildKey = useRef<Key?>(child.key);
useEffect(() {
if (previousChildKey.value == child.key) return null;
previousChildKey.value = child.key;
if (reducedMotion) {
springController.value = 1;
} else {
springController
..stop()
..value = 0.9
..animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 320),
bounce: 0.18,
),
0.9,
1,
0,
snapToEnd: true,
),
);
}
return null;
}, [child.key, reducedMotion]);
return Transform.scale(
scale: springValue,
alignment: alignment,
child: AnimatedSize(
duration: duration,
curve: Curves.easeInOutCubic,
alignment: alignment,
child: AnimatedSwitcher(
duration: duration,
reverseDuration: duration,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: alignment,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInOutCubic,
);
return AnimatedBuilder(
animation: curvedAnimation,
child: child,
builder: (context, child) => IgnorePointer(
ignoring: animation.status == AnimationStatus.reverse,
child: Opacity(
opacity: animation.value.clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, Grid.xs * (1 - animation.value)),
child: Transform.scale(
scale: 0.92 + (0.08 * curvedAnimation.value),
alignment: alignment,
child: child,
),
),
),
),
);
},
child: child,
),
),
);
}
}
class _MentionSuggestions extends StatelessWidget {
final List<MentionCandidate> suggestions;
final Map<String, UserProfile> userCache;

View File

@@ -73,15 +73,24 @@ final mentionCandidatesProvider = Provider.family
ref,
args,
) {
final members =
ref.watch(channelMembersProvider(args.channelId)).asData?.value ??
const <ChannelMember>[];
final channelsAsync = ref.watch(channelsProvider);
final membersAsync = ref.watch(channelMembersProvider(args.channelId));
final sessionStatus = ref.watch(relaySessionProvider).status;
final cachedMembers = channelsAsync.asData == null
? const <ChannelMember>[]
: ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(args.channelId);
final members = channelMembersForAutocomplete(
membersAsync: membersAsync,
sessionStatus: sessionStatus,
cachedMembers: cachedMembers,
);
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final channels =
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final channels = channelsAsync.asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final searchResults =

View File

@@ -0,0 +1,166 @@
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/clipboard_utils.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_search_field.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'invite_create_provider.dart';
part 'invite_create_page/invite_link_section.dart';
part 'invite_create_page/person_invite_section.dart';
/// A page for authorized community members to create and send invitations.
class CommunityInvitePage extends ConsumerWidget {
/// Creates the community invitation page.
const CommunityInvitePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final roleAsync = ref.watch(currentCommunityRoleProvider);
return FrostedScaffold(
backgroundColor: context.colors.surface,
appBar: const FrostedAppBar(title: Text('Invite to community')),
body: roleAsync.when(
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 48,
semanticLabel: 'Checking community permissions',
),
),
error: (_, _) => _InvitePermissionError(
onRetry: () => ref.invalidate(communityMembershipProvider),
),
data: (role) => canManageCommunityInvites(role)
? _CommunityInviteBody(role: role!)
: const _InvitePermissionDenied(),
),
);
}
}
class _CommunityInviteBody extends StatelessWidget {
const _CommunityInviteBody({required this.role});
final CommunityMemberRole role;
@override
Widget build(BuildContext context) {
return ListView(
padding: EdgeInsets.fromLTRB(
0,
frostedAppBarHeight(context) + Grid.xs,
0,
Grid.lg,
),
children: [
const _InviteLinkSection(),
const SizedBox(height: Grid.sm),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
children: [
Expanded(child: Divider(color: context.colors.outlineVariant)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Text(
'Or use npub',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Expanded(child: Divider(color: context.colors.outlineVariant)),
],
),
),
const SizedBox(height: Grid.sm),
_PersonInviteSection(role: role),
],
);
}
}
class _InvitePermissionError extends StatelessWidget {
const _InvitePermissionError({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return _InviteMessage(
icon: LucideIcons.wifiOff,
title: 'Could not check permissions',
body: 'Reconnect to this community and try again.',
action: TextButton(onPressed: onRetry, child: const Text('Retry')),
);
}
}
class _InvitePermissionDenied extends StatelessWidget {
const _InvitePermissionDenied();
@override
Widget build(BuildContext context) {
return const _InviteMessage(
icon: LucideIcons.shieldAlert,
title: 'Invite access required',
body: 'Only community owners and admins can invite people.',
);
}
}
class _InviteMessage extends StatelessWidget {
const _InviteMessage({
required this.icon,
required this.title,
required this.body,
this.action,
});
final IconData icon;
final String title;
final String body;
final Widget? action;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.gutter),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 36, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.xs),
Text(title, style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
Text(
body,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (action != null) ...[const SizedBox(height: Grid.xs), action!],
],
),
),
);
}
}
String _inviteErrorMessage(Object error) =>
error.toString().replaceFirst('Exception: ', '');

View File

@@ -0,0 +1,249 @@
part of '../invite_create_page.dart';
class _InviteLinkSection extends HookConsumerWidget {
const _InviteLinkSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final ttlSeconds = useState(defaultCommunityInviteTtlSeconds);
final maxUses = useState<int?>(null);
final generationId = useRef(0);
final invite = useState<MintedCommunityInvite?>(null);
final isGenerating = useState(true);
final generationError = useState<String?>(null);
Future<void> generate() async {
final requestId = ++generationId.value;
isGenerating.value = true;
invite.value = null;
generationError.value = null;
try {
final created = await ref
.read(communityInviteActionsProvider)
.mintInvite(ttlSeconds: ttlSeconds.value, maxUses: maxUses.value);
if (context.mounted && requestId == generationId.value) {
invite.value = created;
}
} catch (error) {
if (context.mounted && requestId == generationId.value) {
generationError.value = _inviteErrorMessage(error);
}
} finally {
if (context.mounted && requestId == generationId.value) {
isGenerating.value = false;
}
}
}
useEffect(() {
unawaited(generate());
return null;
}, [ttlSeconds.value, maxUses.value]);
final ttlLabel = communityInviteTtlOptions
.firstWhere((option) => option.value == ttlSeconds.value)
.label;
final maxUsesLabel = communityInviteMaxUseOptions
.firstWhere((option) => option.value == maxUses.value)
.label;
Future<void> share(BuildContext buttonContext) async {
final url = invite.value?.url;
if (url == null) return;
final renderBox = buttonContext.findRenderObject() as RenderBox?;
final origin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
try {
await ref.read(shareCommunityInviteProvider)(url, origin);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not share invite link')),
);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
children: [
Expanded(
child: Builder(
builder: (buttonContext) => BuzzActionTile(
key: const Key('community-invite-share-link'),
icon: LucideIcons.share2,
label: 'Share',
isEnabled: invite.value != null,
onTap: () => share(buttonContext),
),
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: BuzzActionTile(
key: const Key('community-invite-copy-link'),
icon: LucideIcons.copy,
label: 'Copy',
isEnabled: invite.value != null,
onTap: () => copyToClipboard(
context,
invite.value!.url,
message: 'Invite link copied',
),
),
),
],
),
),
if (generationError.value case final error?) ...[
const SizedBox(height: Grid.xs),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
children: [
Expanded(
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
TextButton(onPressed: generate, child: const Text('Retry')),
],
),
),
],
const SizedBox(height: Grid.xs),
AppListCard(
key: const Key('community-invite-link-settings'),
dividerIndent: Grid.xs,
children: [
AppListRow(
key: const Key('community-invite-expiry-setting'),
title: 'Expires after',
value: ttlLabel,
trailing: const _InviteRowChevron(),
onTap: isGenerating.value
? null
: () => _showInviteOptionSheet<int>(
context: context,
title: 'Expires after',
value: ttlSeconds.value,
options: communityInviteTtlOptions,
onSelected: (value) => ttlSeconds.value = value,
),
),
AppListRow(
key: const Key('community-invite-max-uses-setting'),
title: 'Limit number of uses',
value: maxUsesLabel,
trailing: const _InviteRowChevron(),
onTap: isGenerating.value
? null
: () => _showInviteOptionSheet<int?>(
context: context,
title: 'Limit number of uses',
value: maxUses.value,
options: communityInviteMaxUseOptions,
onSelected: (value) => maxUses.value = value,
),
),
],
),
],
);
}
}
void _showInviteOptionSheet<T>({
required BuildContext context,
required String title,
required T value,
required List<CommunityInviteOption<T>> options,
required ValueChanged<T> onSelected,
}) {
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => _InviteOptionSheet<T>(
title: title,
value: value,
options: options,
onSelected: onSelected,
),
);
}
class _InviteOptionSheet<T> extends StatelessWidget {
const _InviteOptionSheet({
required this.title,
required this.value,
required this.options,
required this.onSelected,
});
final String title;
final T value;
final List<CommunityInviteOption<T>> options;
final ValueChanged<T> onSelected;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xxs,
),
child: Text(title, style: context.textTheme.titleMedium),
),
for (final option in options)
AppListRow(
key: Key('community-invite-option-${_optionKey(option.label)}'),
title: option.label,
trailing: option.value == value
? Icon(
LucideIcons.check,
size: 18,
color: context.colors.primary,
)
: null,
onTap: () {
onSelected(option.value);
Navigator.of(context).pop();
},
),
const SizedBox(height: Grid.xxs),
],
),
);
}
}
class _InviteRowChevron extends StatelessWidget {
const _InviteRowChevron();
@override
Widget build(BuildContext context) {
return Icon(
LucideIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
);
}
}
String _optionKey(String label) => label
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), '-')
.replaceAll(RegExp(r'^-|-$'), '');

View File

@@ -0,0 +1,283 @@
part of '../invite_create_page.dart';
class _PersonInviteSection extends HookConsumerWidget {
const _PersonInviteSection({required this.role});
final CommunityMemberRole role;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final searchFocusNode = useFocusNode();
final isSearchEditing = useState(false);
final candidatePubkey = useState<String?>(null);
final selectedRole = useState(CommunityMemberRole.member);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
final membership = ref.watch(communityMembershipProvider).asData?.value;
final existingMemberPubkeys = {
...?membership?.pubkeys.map((pubkey) => pubkey.toLowerCase()),
};
final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
useEffect(() {
void handleFocusChange() {
isSearchEditing.value = searchFocusNode.hasFocus;
}
searchFocusNode.addListener(handleFocusChange);
return () => searchFocusNode.removeListener(handleFocusChange);
}, [searchFocusNode]);
void selectCandidate(String pubkey) {
final normalized = pubkey.toLowerCase();
if (normalized == currentPubkey) {
candidatePubkey.value = null;
submitError.value = 'You cannot invite yourself.';
return;
}
if (existingMemberPubkeys.contains(normalized)) {
candidatePubkey.value = null;
submitError.value = 'This person is already in the community.';
return;
}
candidatePubkey.value = normalized;
submitError.value = null;
}
void handleInput(String value) {
submitError.value = null;
final pubkey = parseCommunityInvitePubkey(value);
if (pubkey == null) {
candidatePubkey.value = null;
} else {
selectCandidate(pubkey);
}
}
Future<void> submit(CommunityInviteDirectoryUser invitee) async {
if (isSubmitting.value) return;
isSubmitting.value = true;
submitError.value = null;
try {
await ref
.read(communityInviteActionsProvider)
.inviteMembers(
pubkeys: [invitee.pubkey],
role: role == CommunityMemberRole.owner
? selectedRole.value
: CommunityMemberRole.member,
);
if (!context.mounted) return;
candidatePubkey.value = null;
searchController.clear();
ref.invalidate(communityMembershipProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invited to the community')),
);
} catch (error) {
if (context.mounted) {
submitError.value = _inviteErrorMessage(error);
}
} finally {
if (context.mounted) {
isSubmitting.value = false;
}
}
}
final pubkey = candidatePubkey.value;
final profileAsync = pubkey == null
? null
: ref.watch(communityInviteProfileProvider(pubkey));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: SizedBox(
key: const Key('community-invite-recipient-field'),
width: double.infinity,
height: buzzSearchIdleFieldHeight,
child: BuzzSearchField(
fieldKey: const Key('community-invite-search'),
controller: searchController,
focusNode: searchFocusNode,
hintText: 'Search npub',
iconColor: navigationPrimaryForeground(context),
inputColor: navigationPrimaryForeground(context),
placeholderColor: navigationSecondaryForeground(context),
surfaceColor: navigationSearchSurface(context),
isEditing: isSearchEditing.value,
reduceMotion: MediaQuery.disableAnimationsOf(context),
motionDuration: const Duration(milliseconds: 160),
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
textInputAction: TextInputAction.done,
onTap: searchFocusNode.requestFocus,
onChanged: handleInput,
onSubmitted: (value) {
final pubkey = parseCommunityInvitePubkey(value);
if (pubkey == null && value.trim().isNotEmpty) {
submitError.value = 'Paste a valid npub.';
} else if (pubkey != null) {
selectCandidate(pubkey);
}
},
),
),
),
if (pubkey != null && profileAsync != null) ...[
const SizedBox(height: Grid.xs),
_InviteeResolutionCard(
pubkey: pubkey,
profileAsync: profileAsync,
role: role,
selectedRole: selectedRole.value,
isSubmitting: isSubmitting.value,
onRetry: () =>
ref.invalidate(communityInviteProfileProvider(pubkey)),
onRoleSelected: (value) => selectedRole.value = value,
onInvite: submit,
),
],
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
],
],
);
}
}
class _InviteeResolutionCard extends StatelessWidget {
const _InviteeResolutionCard({
required this.pubkey,
required this.profileAsync,
required this.role,
required this.selectedRole,
required this.isSubmitting,
required this.onRetry,
required this.onRoleSelected,
required this.onInvite,
});
final String pubkey;
final AsyncValue<CommunityInviteDirectoryUser?> profileAsync;
final CommunityMemberRole role;
final CommunityMemberRole selectedRole;
final bool isSubmitting;
final VoidCallback onRetry;
final ValueChanged<CommunityMemberRole> onRoleSelected;
final ValueChanged<CommunityInviteDirectoryUser> onInvite;
@override
Widget build(BuildContext context) {
return profileAsync.when(
loading: () => AppListCard(
children: [
AppListRowRaw(
key: Key('community-invite-resolving-$pubkey'),
leading: const SizedBox.square(
dimension: 40,
child: Center(
child: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Resolving profile',
),
),
),
title: const Text('Resolving profile'),
subtitle: Text(shortCommunityInviteNpub(pubkey)),
),
],
),
error: (_, _) => AppListCard(
children: [
AppListRow(
key: Key('community-invite-resolution-error-$pubkey'),
title: 'Could not resolve profile',
subtitle: shortCommunityInviteNpub(pubkey),
trailing: TextButton(
onPressed: onRetry,
child: const Text('Retry'),
),
),
],
),
data: (invitee) {
if (invitee == null) {
return AppListCard(
children: [
AppListRow(
key: Key('community-invite-unresolved-$pubkey'),
title: 'Invalid npub',
subtitle: shortCommunityInviteNpub(pubkey),
),
],
);
}
return AppListCard(
key: const Key('community-invite-person-card'),
dividerIndent: Grid.xs,
children: [
AppListRow(
key: Key('community-invite-resolved-$pubkey'),
title: shortCommunityInviteNpub(invitee.pubkey),
trailing: FilledButton(
key: const Key('community-invite-submit'),
onPressed: isSubmitting ? null : () => onInvite(invitee),
child: isSubmitting
? const BuzzLoadingIndicator(
size: 16,
semanticLabel: 'Inviting person',
)
: const Text('Invite'),
),
),
if (role == CommunityMemberRole.owner)
AppListRow(
key: const Key('community-invite-role'),
title: 'Role',
value: switch (selectedRole) {
CommunityMemberRole.member => 'Member',
CommunityMemberRole.admin => 'Admin',
CommunityMemberRole.owner => 'Owner',
},
trailing: const _InviteRowChevron(),
onTap: isSubmitting
? null
: () => _showInviteOptionSheet<CommunityMemberRole>(
context: context,
title: 'Role',
value: selectedRole,
options: const [
CommunityInviteOption(
label: 'Member',
value: CommunityMemberRole.member,
),
CommunityInviteOption(
label: 'Admin',
value: CommunityMemberRole.admin,
),
],
onSelected: onRoleSelected,
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,424 @@
import 'dart:convert';
import 'dart:ui' show Rect;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:share_plus/share_plus.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/relay/relay.dart';
/// The default lifetime of a newly minted community invite link.
const defaultCommunityInviteTtlSeconds = 3 * 24 * 60 * 60;
/// A labeled value shown in a community invite settings sheet.
@immutable
class CommunityInviteOption<T> {
/// Creates an option with its user-facing [label] and submitted [value].
const CommunityInviteOption({required this.label, required this.value});
/// The text presented for this option.
final String label;
/// The value applied when this option is selected.
final T value;
}
/// Supported community invite-link lifetimes.
const communityInviteTtlOptions = [
CommunityInviteOption(label: '1 day', value: 24 * 60 * 60),
CommunityInviteOption(label: '3 days', value: 3 * 24 * 60 * 60),
CommunityInviteOption(label: '7 days', value: 7 * 24 * 60 * 60),
CommunityInviteOption(label: '30 days', value: 30 * 24 * 60 * 60),
];
/// Supported usage limits for a community invite link.
const communityInviteMaxUseOptions = <CommunityInviteOption<int?>>[
CommunityInviteOption(label: 'No limit', value: null),
CommunityInviteOption(label: '1 use', value: 1),
CommunityInviteOption(label: '3 uses', value: 3),
CommunityInviteOption(label: '5 uses', value: 5),
CommunityInviteOption(label: '10 uses', value: 10),
CommunityInviteOption(label: '25 uses', value: 25),
];
/// A community invite link minted by the active relay.
@immutable
class MintedCommunityInvite {
/// Creates a parsed invite-link response.
const MintedCommunityInvite({
required this.code,
required this.expiresAt,
required this.url,
required this.maxUses,
required this.usesRemaining,
});
/// The relay-issued invite code.
final String code;
/// The invite's expiration time in Unix seconds.
final int expiresAt;
/// The complete URL that recipients can open.
final String url;
/// The total permitted uses, or null when unlimited.
final int? maxUses;
/// The number of uses still available, or null when unlimited.
final int? usesRemaining;
/// Parses a relay invite response.
factory MintedCommunityInvite.fromJson(Map<String, dynamic> json) {
return MintedCommunityInvite(
code: json['code'] as String,
expiresAt: json['expires_at'] as int,
url: json['url'] as String,
maxUses: json['max_uses'] as int?,
usesRemaining: json['uses_remaining'] as int?,
);
}
}
/// A relay directory identity that can be invited to a community.
@immutable
class CommunityInviteDirectoryUser {
/// Creates a directory identity for [pubkey] and optional profile metadata.
const CommunityInviteDirectoryUser({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.nip05Handle,
});
/// The identity's lowercase hexadecimal Nostr public key.
final String pubkey;
/// The profile's preferred display name, when published.
final String? displayName;
/// The profile image URL, when published.
final String? avatarUrl;
/// The profile's NIP-05 identifier, when published.
final String? nip05Handle;
/// The best available human-readable identity label.
String get label {
final display = displayName?.trim();
if (display != null && display.isNotEmpty) return display;
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty) return nip05;
return shortCommunityInvitePubkey(pubkey);
}
/// A supporting identity label distinct from [label].
String get secondaryLabel {
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty && nip05 != label) return nip05;
return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey;
}
/// The uppercase first character of [label], or `?` when unavailable.
String get initial => label.isEmpty ? '?' : label[0].toUpperCase();
}
/// Abbreviates a hexadecimal public key for compact display.
String shortCommunityInvitePubkey(String pubkey) =>
pubkey.length > 8 ? '${pubkey.substring(0, 8)}…' : pubkey;
/// Encodes and abbreviates a hexadecimal public key as an npub.
String shortCommunityInviteNpub(String pubkey) {
try {
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pubkey,
);
return npub.length > 20
? '${npub.substring(0, 12)}…${npub.substring(npub.length - 6)}'
: npub;
} catch (_) {
return shortCommunityInvitePubkey(pubkey);
}
}
/// Parses a hexadecimal public key or npub, returning lowercase hex.
String? parseCommunityInvitePubkey(String value) {
final normalized = value.trim();
final hexPattern = RegExp(r'^[0-9a-fA-F]{64}$');
if (hexPattern.hasMatch(normalized)) return normalized.toLowerCase();
try {
final decoded = nostr.Nip19.decode(payload: normalized);
if (decoded.prefix != nostr.Nip19Prefix.npub ||
!hexPattern.hasMatch(decoded.data)) {
return null;
}
return decoded.data.toLowerCase();
} catch (_) {
return null;
}
}
/// Builds the Nostr tags for a kind:9030 community member invitation.
@visibleForTesting
List<List<String>> buildCommunityMemberInviteTags({
required String pubkey,
required CommunityMemberRole role,
}) => [
['p', pubkey.trim().toLowerCase()],
['role', role.name],
];
/// Creates invite links and submits direct community member invitations.
abstract class CommunityInviteActions {
/// Mints a community invite link with the requested limits.
Future<MintedCommunityInvite> mintInvite({
required int ttlSeconds,
required int? maxUses,
});
/// Invites each public key to the active community with [role].
Future<void> inviteMembers({
required Iterable<String> pubkeys,
required CommunityMemberRole role,
});
}
/// Relay-backed implementation of [CommunityInviteActions].
class RelayCommunityInviteActions implements CommunityInviteActions {
/// Creates invite actions bound to the active relay and signing session.
RelayCommunityInviteActions({
required http.Client httpClient,
required String baseUrl,
required String? nsec,
required SignedEventRelay signedEventRelay,
required bool Function() isCommunityActive,
}) : _httpClient = httpClient,
_baseUrl = baseUrl,
_nsec = nsec,
_signedEventRelay = signedEventRelay,
_isCommunityActive = isCommunityActive;
final http.Client _httpClient;
final String _baseUrl;
final String? _nsec;
final SignedEventRelay _signedEventRelay;
final bool Function() _isCommunityActive;
void _ensureCommunityActive() {
if (!_isCommunityActive()) {
throw StateError('Invite cancelled because the active community changed');
}
}
@override
Future<MintedCommunityInvite> mintInvite({
required int ttlSeconds,
required int? maxUses,
}) async {
_ensureCommunityActive();
final url = Uri.parse(_baseUrl).resolve('/api/invites').toString();
final body = <String, Object>{'ttl_secs': ttlSeconds};
if (maxUses != null) body['max_uses'] = maxUses;
final bodyBytes = utf8.encode(jsonEncode(body));
final response = await _httpClient
.post(
Uri.parse(url),
headers: {
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: _nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
)
.timeout(const Duration(seconds: 15));
_ensureCommunityActive();
final dynamic decoded;
try {
decoded = jsonDecode(response.body);
} on FormatException {
throw Exception('Relay returned an invalid invite response');
}
if (response.statusCode < 200 || response.statusCode >= 300) {
final rawMessage = decoded is Map<String, dynamic>
? decoded['error']
: null;
final message = rawMessage is String ? rawMessage : null;
throw Exception(message ?? 'HTTP ${response.statusCode}');
}
if (decoded is! Map<String, dynamic>) {
throw Exception('Relay returned an invalid invite response');
}
return MintedCommunityInvite.fromJson(decoded);
}
@override
Future<void> inviteMembers({
required Iterable<String> pubkeys,
required CommunityMemberRole role,
}) async {
final normalizedPubkeys = <String>{};
for (final pubkey in pubkeys) {
final parsed = parseCommunityInvitePubkey(pubkey);
if (parsed != null) normalizedPubkeys.add(parsed);
}
for (final pubkey in normalizedPubkeys) {
_ensureCommunityActive();
await _signedEventRelay.submit(
kind: EventKind.relayAdminAddMember,
content: '',
tags: buildCommunityMemberInviteTags(pubkey: pubkey, role: role),
);
}
_ensureCommunityActive();
}
}
/// Supplies the HTTP client used to mint community invite links.
final communityInviteHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// Supplies invite operations bound to the current community session.
final communityInviteActionsProvider = Provider<CommunityInviteActions>((ref) {
final config = ref.watch(relayConfigProvider);
final session = ref.read(relaySessionProvider.notifier);
return RelayCommunityInviteActions(
httpClient: ref.watch(communityInviteHttpClientProvider),
baseUrl: config.baseUrl,
nsec: config.nsec,
signedEventRelay: SignedEventRelay(session: session, nsec: config.nsec),
isCommunityActive: () {
final current = ref.read(relayConfigProvider);
return current.baseUrl == config.baseUrl && current.nsec == config.nsec;
},
);
});
List<CommunityInviteDirectoryUser> _directoryUsersFromEvents(
List<NostrEvent> events,
) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) continue;
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}
final users = [
for (final event in latestByPubkey.values)
if (ProfileData.fromEvent(event) case final profile)
CommunityInviteDirectoryUser(
pubkey: profile.pubkey.toLowerCase(),
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
),
];
users.sort((a, b) {
final byLabel = a.label.toLowerCase().compareTo(b.label.toLowerCase());
return byLabel != 0 ? byLabel : a.pubkey.compareTo(b.pubkey);
});
return users;
}
/// Resolves a pasted pubkey to its published profile on the active relay.
///
/// A valid npub already identifies an exact public key, so missing kind:0
/// metadata falls back to a pubkey-only person instead of blocking the invite.
/// Relay failures still surface as errors and malformed inputs return null.
final communityInviteProfileProvider = FutureProvider.autoDispose
.family<CommunityInviteDirectoryUser?, String>((ref, pubkey) async {
final normalized = parseCommunityInvitePubkey(pubkey);
if (normalized == null) return null;
ref.watch(relayConfigProvider);
final events = await ref.watch(relaySessionProvider.notifier).queryRelay([
NostrFilters.profile(normalized),
]);
for (final user in _directoryUsersFromEvents(events)) {
if (user.pubkey == normalized) return user;
}
return CommunityInviteDirectoryUser(pubkey: normalized);
});
/// Lists inviteable relay identities, excluding the signed-in user.
final communityInviteDirectoryProvider =
FutureProvider.autoDispose<List<CommunityInviteDirectoryUser>>((ref) async {
ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
final events = await ref.watch(relaySessionProvider.notifier).queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
final directoryUsers = _directoryUsersFromEvents(
events,
).where((user) => user.pubkey != currentPubkey).toList();
if (directoryUsers.isNotEmpty) return directoryUsers;
// Match the existing mobile person picker: older relays may not support
// a paged kind:0 directory, so fall back to the membership snapshot and
// hydrate whatever profiles are available.
final membership = await ref.watch(communityMembershipProvider.future);
final memberPubkeys = membership.pubkeys
.where((pubkey) => pubkey != currentPubkey)
.toList();
if (memberPubkeys.isEmpty) return const [];
final profileEvents = await ref
.watch(relaySessionProvider.notifier)
.queryRelay([NostrFilters.profilesBatch(memberPubkeys)]);
final profilesByPubkey = {
for (final user in _directoryUsersFromEvents(profileEvents))
user.pubkey: user,
};
final fallbackUsers = [
for (final pubkey in memberPubkeys)
profilesByPubkey[pubkey] ??
CommunityInviteDirectoryUser(pubkey: pubkey),
];
fallbackUsers.sort((a, b) {
final byLabel = a.label.toLowerCase().compareTo(b.label.toLowerCase());
return byLabel != 0 ? byLabel : a.pubkey.compareTo(b.pubkey);
});
return fallbackUsers;
});
/// Searches the active relay for inviteable identities matching a query.
final communityInviteDirectorySearchProvider = FutureProvider.autoDispose
.family<List<CommunityInviteDirectoryUser>, String>((ref, query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) {
return ref.watch(communityInviteDirectoryProvider.future);
}
ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
final events = await ref.watch(relaySessionProvider.notifier).queryRelay([
NostrFilters.searchUsers(trimmed, limit: 50),
]);
return _directoryUsersFromEvents(
events,
).where((user) => user.pubkey != currentPubkey).toList();
});
/// Shares [inviteUrl] from an optional platform anchor rectangle.
typedef ShareCommunityInvite =
Future<void> Function(String inviteUrl, Rect? sharePositionOrigin);
/// Supplies the native share-sheet action for community invite links.
final shareCommunityInviteProvider = Provider<ShareCommunityInvite>((ref) {
return (inviteUrl, sharePositionOrigin) async {
await SharePlus.instance.share(
ShareParams(text: inviteUrl, sharePositionOrigin: sharePositionOrigin),
);
};
});

View File

@@ -10,7 +10,7 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
@@ -216,7 +216,7 @@ class UserProfileSheet extends HookConsumerWidget {
children: [
if (pk != currentPubkey) ...[
Expanded(
child: _ProfileActionTile(
child: BuzzActionTile(
icon: isOpeningDirectMessage.value
? null
: LucideIcons.messageSquare,
@@ -224,13 +224,14 @@ class UserProfileSheet extends HookConsumerWidget {
? 'Opening…'
: 'Message',
isLoading: isOpeningDirectMessage.value,
loadingSemanticLabel: 'Opening direct message',
onTap: openDirectMessage,
),
),
const SizedBox(width: Grid.twelve),
],
Expanded(
child: _ProfileActionTile(
child: BuzzActionTile(
icon: copied.value
? LucideIcons.check
: LucideIcons.key,
@@ -371,59 +372,6 @@ class _ProfilePresenceChip extends StatelessWidget {
}
}
class _ProfileActionTile extends StatelessWidget {
const _ProfileActionTile({
required this.icon,
required this.label,
required this.onTap,
this.isLoading = false,
});
final IconData? icon;
final String label;
final VoidCallback onTap;
final bool isLoading;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: isLoading
? null
: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
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: 'Opening direct message',
)
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,
),
),
],
),
),
);
}
class _ProfileAvatar extends StatelessWidget {
final String? avatarUrl;
final String initial;

View File

@@ -9,6 +9,7 @@ import '../../shared/mentions/mention_tags.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_search_field.dart';
import '../../shared/widgets/filter_chip_bar.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
@@ -27,8 +28,6 @@ import '../profile/user_profile.dart';
import 'recent_searches_provider.dart';
import 'search_provider.dart';
part 'search_page/motion_field.dart';
enum _SearchFilter { all, messages, channels, people }
const _searchFieldMinHeight = 36.0;
@@ -318,14 +317,15 @@ class SearchPage extends HookConsumerWidget {
// native input connection before the keyboard is shown.
child: SizedBox(
key: const Key('search-field-container'),
child: _SearchMotionField(
child: BuzzSearchField(
controller: textController,
focusNode: focusNode,
hintText: 'Search messages, channels, and people',
iconColor: searchPrimaryColor,
inputColor: searchPrimaryColor,
placeholderColor: searchPlaceholderColor,
surfaceColor: searchSurfaceColor,
isSearchEditing: isSearchEditing.value,
isEditing: isSearchEditing.value,
reduceMotion: reduceMotion,
motionDuration: _searchFieldMoveDuration,
onTap: activateSearch,

View File

@@ -10,6 +10,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
@@ -21,16 +22,25 @@ import 'accent_picker_page.dart';
import 'theme_picker_page.dart';
part 'settings_page/appearance_section.dart';
part 'settings_page/community_section.dart';
part 'settings_page/connection_section.dart';
class SettingsPage extends HookConsumerWidget {
/// Creates the settings page.
const SettingsPage({
super.key,
required this.profileHeader,
required this.invitePageBuilder,
required this.identityRecoveryPageBuilder,
});
/// Header widget displayed at the top of settings.
final Widget profileHeader;
/// Builds the community-invite page pushed from the invite settings row.
final WidgetBuilder invitePageBuilder;
/// Builds the identity-recovery page pushed from the recovery settings row.
final WidgetBuilder identityRecoveryPageBuilder;
@override
@@ -71,6 +81,7 @@ class SettingsPage extends HookConsumerWidget {
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
children: [
profileHeader,
_CommunitySection(invitePageBuilder: invitePageBuilder),
const _AppearanceSection(),
_ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder,

View File

@@ -0,0 +1,29 @@
part of '../settings_page.dart';
class _CommunitySection extends ConsumerWidget {
const _CommunitySection({required this.invitePageBuilder});
final WidgetBuilder invitePageBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
final roleAsync = ref.watch(currentCommunityRoleProvider);
if (!roleAsync.hasError && !canManageCommunityInvites(roleAsync.value)) {
return const SizedBox.shrink();
}
return AppListCard(
label: 'Community',
children: [
AppListRow(
icon: LucideIcons.userPlus,
title: 'Invite to community',
trailing: const _RowChevron(),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: invitePageBuilder)),
),
],
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
/// Role levels assigned by a community membership snapshot.
enum CommunityMemberRole {
/// Full community ownership permissions.
owner,
/// Community administration permissions.
admin,
/// Standard community membership permissions.
member,
}
/// A member and their role in the active community.
@immutable
class CommunityMember {
/// Creates a community member.
const CommunityMember({required this.pubkey, required this.role});
/// The member's normalized hexadecimal public key.
final String pubkey;
/// The member's role in the community.
final CommunityMemberRole role;
}
/// The latest membership list published by the active community.
@immutable
class CommunityMembershipSnapshot {
/// Creates a community membership snapshot.
const CommunityMembershipSnapshot({
required this.snapshotFound,
required this.members,
});
/// Whether the relay returned a membership snapshot event.
final bool snapshotFound;
/// The valid members parsed from the latest snapshot.
final List<CommunityMember> members;
/// Returns the role assigned to [pubkey], or `null` when it is not a member.
CommunityMemberRole? roleFor(String? pubkey) {
final normalized = pubkey?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return null;
for (final member in members) {
if (member.pubkey == normalized) return member.role;
}
return null;
}
/// The normalized public keys in this membership snapshot.
Set<String> get pubkeys => {for (final member in members) member.pubkey};
}
CommunityMemberRole _communityMemberRole(String? value) => switch (value) {
'owner' => CommunityMemberRole.owner,
'admin' => CommunityMemberRole.admin,
_ => CommunityMemberRole.member,
};
/// Parses the current kind:13534 membership snapshot.
///
/// Buzz emits `["member", pubkey, role]`. Older NIP-29-compatible relays may
/// use `["p", pubkey, relay, role]`, so mobile accepts both forms just like
/// desktop does.
@visibleForTesting
CommunityMembershipSnapshot communityMembershipFromEvents(
List<NostrEvent> events,
) {
if (events.isEmpty) {
return const CommunityMembershipSnapshot(snapshotFound: false, members: []);
}
final event = events.reduce(
(latest, candidate) =>
candidate.createdAt > latest.createdAt ? candidate : latest,
);
final members = <CommunityMember>[];
final seen = <String>{};
final pubkeyPattern = RegExp(r'^[0-9a-f]{64}$');
for (final tag in event.tags) {
if (tag.length < 2 || (tag[0] != 'member' && tag[0] != 'p')) continue;
final pubkey = tag[1].trim().toLowerCase();
if (!pubkeyPattern.hasMatch(pubkey) || !seen.add(pubkey)) continue;
final role = tag[0] == 'member'
? (tag.length >= 3 ? tag[2] : null)
: (tag.length >= 4 ? tag[3] : null);
members.add(
CommunityMember(pubkey: pubkey, role: _communityMemberRole(role)),
);
}
return CommunityMembershipSnapshot(snapshotFound: true, members: members);
}
/// Relay membership for the active community.
///
/// The HTTP query resolves from the first response rather than waiting for a
/// WebSocket EOSE frame, and watching [relayConfigProvider] makes the result
/// community-scoped.
final communityMembershipProvider =
FutureProvider.autoDispose<CommunityMembershipSnapshot>((ref) async {
ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider.notifier);
final events = await session.queryRelay([NostrFilters.relayMembers()]);
return communityMembershipFromEvents(events);
});
/// The active user's role in the current community.
final currentCommunityRoleProvider = Provider<AsyncValue<CommunityMemberRole?>>(
(ref) {
final pubkey = ref.watch(myPubkeyProvider);
return ref
.watch(communityMembershipProvider)
.whenData((snapshot) => snapshot.roleFor(pubkey));
},
);
/// Whether [role] is allowed to create community invitations.
bool canManageCommunityInvites(CommunityMemberRole? role) =>
role == CommunityMemberRole.owner || role == CommunityMemberRole.admin;

View File

@@ -203,7 +203,7 @@ abstract final class NostrFilters {
/// Relay membership list (kind:13534).
static NostrFilter relayMembers() =>
const NostrFilter(kinds: [13534], limit: 1);
const NostrFilter(kinds: [EventKind.relayMembership], limit: 1);
/// Agent profiles (kind:10100).
static NostrFilter agentProfiles() =>

View File

@@ -10,6 +10,12 @@ abstract final class EventKind {
static const contactList = 3;
static const deletion = 5;
static const reaction = 7;
/// Kind:9030 event requesting that the relay add a community member.
static const relayAdminAddMember = 9030;
/// Kind:13534 event containing the current relay-community membership.
static const relayMembership = 13534;
static const streamMessage = 9;
static const nip29DeleteEvent = 9005;
static const presenceUpdate = 20001;

View File

@@ -7,11 +7,21 @@ import 'app_list_inset.dart';
/// it. Rows inside are hairline-separated and inset to the card rather than the
/// page, via [AppListInset].
class AppListCard extends StatelessWidget {
const AppListCard({super.key, this.label, required this.children});
const AppListCard({
super.key,
this.label,
this.dividerIndent,
required this.children,
});
/// Rendered above the card in sentence case, as written — no uppercasing.
final String? label;
/// Separator inset from the card edge. Defaults to the standard row label
/// column, clearing a leading icon. Icon-free cards can pass [_inset] so
/// separators align with their row content on both sides.
final double? dividerIndent;
final List<Widget> children;
static const _inset = Grid.xs;
@@ -29,7 +39,7 @@ class AppListCard extends StatelessWidget {
Divider(
height: 1,
thickness: 1,
indent: _dividerIndent,
indent: dividerIndent ?? _dividerIndent,
endIndent: _inset,
// The scheme's own border tokens are derived from the page surface,
// which lands them within a few levels of the card fill — invisible.

View File

@@ -0,0 +1,84 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/theme.dart';
import 'buzz_loading_indicator.dart';
/// Equal-width icon action used by profile and profile-adjacent surfaces.
class BuzzActionTile extends StatelessWidget {
/// Creates an action tile with an optional loading state.
const BuzzActionTile({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.isEnabled = true,
this.isLoading = false,
this.loadingSemanticLabel,
});
/// Icon shown when the tile is not loading.
final IconData? icon;
/// Label shown below the icon.
final String label;
/// Called when the tile is tapped.
final VoidCallback onTap;
/// Whether the tile accepts taps.
final bool isEnabled;
/// Whether to show a loading indicator instead of [icon].
final bool isLoading;
/// Accessibility label for the loading indicator.
final String? loadingSemanticLabel;
@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,
),
),
],
),
),
),
);
}
}

View File

@@ -1,43 +1,100 @@
part of '../search_page.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
const _searchIdleIconSize = 26.0;
const _searchCompactIconSize = 18.0;
const _searchFieldHint = 'Search messages, channels, and people';
const _searchIdleIconInset = Grid.xxs;
const _searchIdleTextInset =
import '../theme/theme.dart';
/// Height of an idle [BuzzSearchField].
const double buzzSearchIdleFieldHeight = 45;
/// Font size used for idle [BuzzSearchField] text.
const double buzzSearchIdleTextSize = 15;
const double _searchIdleIconSize = 26;
const double _searchCompactIconSize = 18;
const double _searchIdleIconInset = Grid.xxs;
const double _searchIdleTextInset =
_searchIdleIconInset + _searchIdleIconSize + Grid.xxs;
const _searchCompactTextInset =
const double _searchCompactTextInset =
_searchIdleIconInset + _searchCompactIconSize + Grid.xxs;
class _SearchMotionField extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final Color iconColor;
final Color inputColor;
final Color placeholderColor;
final Color surfaceColor;
final bool isSearchEditing;
final bool reduceMotion;
final Duration motionDuration;
final VoidCallback onTap;
final ValueChanged<String> onChanged;
final ValueChanged<String> onSubmitted;
const _SearchMotionField({
/// Buzz's global-search text field treatment, shared by search-like inputs.
class BuzzSearchField extends StatelessWidget {
/// Creates a search field with Buzz's shared styling.
const BuzzSearchField({
required this.controller,
required this.focusNode,
required this.hintText,
required this.iconColor,
required this.inputColor,
required this.placeholderColor,
required this.surfaceColor,
required this.isSearchEditing,
required this.isEditing,
required this.reduceMotion,
required this.motionDuration,
required this.onTap,
required this.onChanged,
required this.onSubmitted,
this.fieldKey = const Key('search-field'),
this.autocorrect = true,
this.enableSuggestions = true,
this.enabled = true,
this.textInputAction = TextInputAction.search,
super.key,
});
/// Controller that owns the field's editable text.
final TextEditingController controller;
/// Node that controls the field's focus.
final FocusNode focusNode;
/// Placeholder shown while the field is idle and empty.
final String hintText;
/// Color applied to the search icon.
final Color iconColor;
/// Color applied to entered text.
final Color inputColor;
/// Color applied to [hintText].
final Color placeholderColor;
/// Background color of the field.
final Color surfaceColor;
/// Whether the field is in its compact editing state.
final bool isEditing;
/// Whether to suppress field animations for reduced-motion users.
final bool reduceMotion;
/// Duration used by the field's editing-state animation.
final Duration motionDuration;
/// Called when the field is tapped.
final VoidCallback onTap;
/// Called whenever the field's text changes.
final ValueChanged<String> onChanged;
/// Called when the user submits the field.
final ValueChanged<String> onSubmitted;
/// Key assigned to the underlying text field.
final Key fieldKey;
/// Whether the text field should autocorrect user input.
final bool autocorrect;
/// Whether the platform should offer text suggestions.
final bool enableSuggestions;
/// Whether the text field accepts user input.
final bool enabled;
/// Action displayed on the keyboard's submit key.
final TextInputAction textInputAction;
@override
Widget build(BuildContext context) => DecoratedBox(
decoration: BoxDecoration(
@@ -52,33 +109,36 @@ class _SearchMotionField extends StatelessWidget {
child: SizedBox(
width: double.infinity,
child: TextField(
key: const Key('search-field'),
key: fieldKey,
controller: controller,
focusNode: focusNode,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
enabled: enabled,
decoration: InputDecoration(
hintText: isSearchEditing ? null : _searchFieldHint,
hintText: isEditing ? null : hintText,
hintStyle: searchInputTextStyle.copyWith(
color: placeholderColor,
fontSize: _searchIdleTextSize,
height: 20 / _searchIdleTextSize,
fontSize: buzzSearchIdleTextSize,
height: 20 / buzzSearchIdleTextSize,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(
left: isSearchEditing
left: isEditing
? _searchCompactTextInset
: _searchIdleTextInset,
right: Grid.xxs,
top: isSearchEditing ? _searchFieldVerticalPadding : 0,
bottom: isSearchEditing ? _searchFieldVerticalPadding : 0,
top: isEditing ? Grid.xxs : 0,
bottom: isEditing ? Grid.xxs : 0,
),
),
style: searchInputTextStyle.copyWith(color: inputColor),
textAlignVertical: TextAlignVertical.center,
textAlign: TextAlign.start,
textInputAction: TextInputAction.search,
textInputAction: textInputAction,
onTap: onTap,
onChanged: onChanged,
onSubmitted: onSubmitted,
@@ -94,7 +154,7 @@ class _SearchMotionField extends StatelessWidget {
child: AnimatedScale(
duration: reduceMotion ? Duration.zero : motionDuration,
curve: Curves.easeInOutCubic,
scale: isSearchEditing
scale: isEditing
? _searchCompactIconSize / _searchIdleIconSize
: 1,
child: Icon(

View File

@@ -222,6 +222,114 @@ void main() {
});
});
group('channelMembersProvider', () {
test('waits for the relay connection before fetching members', () async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final subscription = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
);
addTearDown(subscription.close);
expect(
await container.read(channelMembersProvider(_channelId).future),
isEmpty,
);
expect(session.historyQueryCount, 0);
session.connect();
await container.pump();
final members = await container.read(
channelMembersProvider(_channelId).future,
);
expect(session.historyQueryCount, 1);
expect(members, hasLength(1));
expect(members.single.pubkey, _memberPubkey);
expect(members.single.role, 'admin');
});
test(
'keeps the provider member snapshot available during reconnect',
() async {
final session = _ConnectionAwareRelaySession();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final subscription = container.listen(
channelMembersProvider(_channelId),
(_, _) {},
);
addTearDown(subscription.close);
session.connect();
await container.pump();
final connectedMembers = await container.read(
channelMembersProvider(_channelId).future,
);
expect(connectedMembers, hasLength(1));
expect(session.historyQueryCount, 1);
session.setStatus(SessionStatus.reconnecting);
await container.pump();
final reconnectingMembers = container
.read(channelMembersProvider(_channelId))
.asData
?.value;
expect(reconnectingMembers, connectedMembers);
expect(session.historyQueryCount, 1);
},
);
test('keeps the member snapshot available during reconnect', () {
final cachedMembers = [
ChannelMember(
pubkey: _memberPubkey,
role: 'member',
joinedAt: DateTime.fromMillisecondsSinceEpoch(1000),
),
];
final refreshedMember = ChannelMember(
pubkey: _memberPubkey,
role: 'admin',
joinedAt: DateTime.fromMillisecondsSinceEpoch(2000),
);
expect(
channelMembersForAutocomplete(
membersAsync: const AsyncData([]),
sessionStatus: SessionStatus.connected,
cachedMembers: cachedMembers,
),
isEmpty,
);
expect(
channelMembersForAutocomplete(
membersAsync: const AsyncData([]),
sessionStatus: SessionStatus.reconnecting,
cachedMembers: cachedMembers,
),
same(cachedMembers),
);
expect(
channelMembersForAutocomplete(
membersAsync: AsyncData([refreshedMember]),
sessionStatus: SessionStatus.connected,
cachedMembers: cachedMembers,
),
[refreshedMember],
);
});
});
group('directory providers relay-config invalidation', () {
NostrEvent profile(String pubkey, String name) => NostrEvent(
id: '$pubkey-profile',
@@ -335,6 +443,55 @@ void main() {
});
}
const _channelId = '11111111-1111-4111-8111-111111111111';
const _memberPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
class _ConnectionAwareRelaySession extends RelaySessionNotifier {
int historyQueryCount = 0;
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
void connect() {
state = const SessionState(status: SessionStatus.connected);
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
historyQueryCount++;
return [
NostrEvent(
id: 'members',
pubkey: 'owner',
createdAt: 1,
kind: 39002,
tags: const [
['d', _channelId],
['p', _memberPubkey, 'wss://relay.example', 'admin'],
],
content: '',
sig: 'sig',
),
];
}
void setStatus(SessionStatus status) {
state = SessionState(status: status);
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
/// Fake [RelaySessionNotifier] that serves canned kind:0 profile events from
/// [queryRelay] and counts directory vs. search queries.
class _DirectoryFakeRelaySession extends RelaySessionNotifier {

View File

@@ -21,6 +21,42 @@ import 'package:buzz/shared/relay/relay.dart';
void main() {
const myPk = 'me';
test(
'seeds members from the channel-list snapshot during reconnect',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk, additionalPubkey: 'alice')],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final memberQueryCount = session.historyFilters
.where(
(filter) =>
filter.kinds.contains(39002) && filter.tags['#d'] != null,
)
.length;
session.setStatus(SessionStatus.reconnecting);
final members = await container.read(
channelMembersProvider(_channelA).future,
);
expect(members.map((member) => member.pubkey), [myPk, 'alice']);
expect(
session.historyFilters
.where(
(filter) =>
filter.kinds.contains(39002) && filter.tags['#d'] != null,
)
.length,
memberQueryCount,
);
},
);
test(
'subscribes per-channel with #h tags (only joined, non-archived)',
() async {
@@ -54,6 +90,25 @@ void main() {
},
);
test('retains channel-list member snapshots for immediate reuse', () async {
final joinedAt = DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true);
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk, additionalPubkey: 'alice')],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final members = container
.read(channelsProvider.notifier)
.cachedMembersForChannel(_channelA);
expect(members, hasLength(2));
expect(members.map((member) => member.pubkey), [myPk, 'alice']);
expect(members.every((member) => member.joinedAt == joinedAt), isTrue);
});
test(
'refreshing an unchanged channel set issues zero new live REQs',
() async {
@@ -529,7 +584,11 @@ const _channelB = '22222222-2222-4222-8222-222222222222';
const _channelD = '44444444-4444-4444-8444-444444444444';
/// Build a kind:39002 membership event tagged with the channel id and member.
NostrEvent _membership(String channelId, String pubkey) => NostrEvent(
NostrEvent _membership(
String channelId,
String pubkey, {
String? additionalPubkey,
}) => NostrEvent(
id: 'mem-$channelId',
pubkey: 'creator',
createdAt: 1,
@@ -537,6 +596,7 @@ NostrEvent _membership(String channelId, String pubkey) => NostrEvent(
tags: [
['d', channelId],
['p', pubkey],
if (additionalPubkey != null) ['p', additionalPubkey],
],
content: '',
sig: 'sig',

View File

@@ -173,8 +173,10 @@ Widget _buildComposeBar({
Future<List<ChannelMember>>? membersFuture,
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
List<Channel> channels = const <Channel>[],
List<ChannelMember> cachedMembers = const <ChannelMember>[],
String? currentPubkey,
bool? supportsShowingSystemContextMenu,
bool? disableAnimations,
TextScaler? textScaler,
List<CustomEmoji> customEmoji = const <CustomEmoji>[],
RelayConfigNotifier Function()? relayConfig,
@@ -198,14 +200,22 @@ Widget _buildComposeBar({
relayConfig ?? _FakeRelayConfigNotifier.new,
),
savedPrefsProvider.overrideWithValue(_testPrefs),
channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)),
channelsProvider.overrideWith(
() => _FakeChannelsNotifier(channels, cachedMembers: cachedMembers),
),
],
child: MaterialApp(
theme: AppTheme.light(),
builder: supportsShowingSystemContextMenu == null && textScaler == null
builder:
supportsShowingSystemContextMenu == null &&
disableAnimations == null &&
textScaler == null
? null
: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations:
disableAnimations ??
MediaQuery.disableAnimationsOf(context),
supportsShowingSystemContextMenu:
supportsShowingSystemContextMenu ??
MediaQuery.of(context).supportsShowingSystemContextMenu,
@@ -396,8 +406,16 @@ class _RecordingRelaySocket extends RelaySocket {
class _FakeChannelsNotifier extends ChannelsNotifier {
final List<Channel> _channels;
final List<ChannelMember> _cachedMembers;
_FakeChannelsNotifier(this._channels);
_FakeChannelsNotifier(
this._channels, {
List<ChannelMember> cachedMembers = const [],
}) : _cachedMembers = cachedMembers;
@override
List<ChannelMember> cachedMembersForChannel(String channelId) =>
channelId == 'channel-1' ? _cachedMembers : const [];
@override
Future<List<Channel>> build() async => _channels;
@@ -406,6 +424,10 @@ class _FakeChannelsNotifier extends ChannelsNotifier {
Future<void> refresh() async {
state = AsyncData(_channels);
}
void notifyWithCopy() {
state = AsyncData([..._channels]);
}
}
void main() {
@@ -553,6 +575,116 @@ void main() {
);
});
testWidgets('return inserts a newline and sending stays on the button', (
tester,
) async {
var sendCount = 0;
String? sentContent;
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
sendCount += 1;
sentContent = content;
},
),
);
await _expandComposer(tester);
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.keyboardType, TextInputType.multiline);
expect(textField.textInputAction, TextInputAction.newline);
expect(textField.onSubmitted, isNull);
await tester.enterText(find.byType(TextField), 'First line\nSecond line');
await tester.pumpAndSettle();
expect(sendCount, 0);
expect(textField.controller!.text, 'First line\nSecond line');
final sendButton = find
.ancestor(
of: find.byIcon(LucideIcons.arrowUp),
matching: find.byType(IconButton),
)
.hitTestable();
await tester.tap(sendButton);
await tester.pumpAndSettle();
expect(sendCount, 1);
expect(sentContent, 'First line\nSecond line');
});
testWidgets('smoothly resizes the text field when a new line is added', (
tester,
) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'First line');
await tester.pumpAndSettle();
final heightMotion = find.byKey(
const ValueKey('composer-text-height-motion'),
);
final animation = tester.widget<AnimatedSize>(heightMotion);
expect(animation.duration, const Duration(milliseconds: 140));
expect(animation.curve, Curves.easeOutCubic);
final oneLineHeight = tester.getSize(heightMotion).height;
await tester.enterText(
find.byType(TextField),
'First line\nSecond line\nThird line',
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
final midResizeHeight = tester.getSize(heightMotion).height;
await tester.pumpAndSettle();
final threeLineHeight = tester.getSize(heightMotion).height;
expect(midResizeHeight, greaterThan(oneLineHeight));
expect(midResizeHeight, lessThan(threeLineHeight));
});
testWidgets('skips composer height motion when animations are disabled', (
tester,
) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
disableAnimations: true,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
expect(find.byType(AnimatedSize), findsNothing);
expect(
find.byKey(const ValueKey('composer-text-height-motion')),
findsOneWidget,
);
});
testWidgets('attachment control responds while the composer is expanding', (
tester,
) async {
@@ -702,6 +834,98 @@ void main() {
expect(textField.focusNode!.hasFocus, isTrue);
});
testWidgets('iOS selection handles resize a draft with the system menu', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
try {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
supportsShowingSystemContextMenu: true,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'abc def ghi');
await tester.pump();
final editableState = tester.state<EditableTextState>(
find.byType(EditableText),
);
final renderEditable = editableState.renderEditable;
Offset textPosition(int offset) {
final point = renderEditable
.getEndpointsForSelection(TextSelection.collapsed(offset: offset))
.single;
return renderEditable.localToGlobal(point.point) - const Offset(0, 2);
}
final wordPosition = textPosition(5);
await tester.tapAt(wordPosition, pointer: 7);
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(wordPosition, pointer: 7);
await tester.pumpAndSettle();
final controller = tester
.widget<TextField>(find.byType(TextField))
.controller!;
expect(
tester
.widget<TextField>(find.byType(TextField))
.magnifierConfiguration,
same(TextMagnifierConfiguration.disabled),
);
expect(
controller.selection,
const TextSelection(baseOffset: 4, extentOffset: 7),
);
final contextMenuBuilder = tester
.widget<TextField>(find.byType(TextField))
.contextMenuBuilder;
final container = ProviderScope.containerOf(
tester.element(find.byType(ComposeBar)),
);
(container.read(channelsProvider.notifier) as _FakeChannelsNotifier)
.notifyWithCopy();
await tester.pump();
expect(
tester.widget<TextField>(find.byType(TextField)).contextMenuBuilder,
same(contextMenuBuilder),
);
expect(tester.takeException(), isNull);
final endpoint = renderEditable
.getEndpointsForSelection(controller.selection)
.last;
final gesture = await tester.startGesture(
renderEditable.localToGlobal(endpoint.point),
pointer: 7,
);
await tester.pump();
await gesture.moveTo(textPosition(11));
await tester.pump();
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 11);
expect(tester.takeException(), isNull);
} finally {
await tester.pumpWidget(const SizedBox.shrink());
tester.view.reset();
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('composer controls use selection haptics', (tester) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -788,6 +1012,141 @@ void main() {
);
});
testWidgets('shows cached member mentions before the refresh completes', (
tester,
) async {
final pendingMembers = Completer<List<ChannelMember>>();
addTearDown(() {
if (!pendingMembers.isCompleted) pendingMembers.complete(const []);
});
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
membersFuture: pendingMembers.future,
cachedMembers: [
ChannelMember(
pubkey: 'a' * 64,
role: 'member',
joinedAt: DateTime.fromMillisecondsSinceEpoch(1000),
displayName: 'Alice',
),
],
channels: [_makeCurrentChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.tap(find.byIcon(LucideIcons.atSign));
await tester.pump();
expect(pendingMembers.isCompleted, isFalse);
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsOneWidget,
);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('dismisses mention suggestions in the selection frame', (
tester,
) async {
final signer = nostr.Keys.generate();
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(signer.nsec),
currentPubkey: signer.public,
relayAgents: [_testAgent('f' * 64)],
channels: [_makeCurrentChannel(), _makeSharedMemberChannel()],
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@');
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsOneWidget,
);
await tester.tap(find.text('Helper Bot'));
await tester.pump();
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsNothing,
);
expect(find.byType(AnimatedSize), findsOneWidget);
expect(
find.byKey(const ValueKey('composer-text-height-motion')),
findsOneWidget,
);
final controller = tester
.widget<TextField>(find.byType(TextField))
.controller!;
expect(controller.text, '@Helper Bot ');
expect(controller.selection, const TextSelection.collapsed(offset: 12));
// Rendering the selected agent chip notifies the editor again. That
// display-only update must not restart the completed mention query.
await tester.pump(const Duration(milliseconds: 300));
expect(
find.byKey(const ValueKey('mention-suggestions-popover')),
findsNothing,
);
});
testWidgets('reuses rich text layout for selection-only movement', (
tester,
) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'abc def ghi');
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
final controller = textField.controller!;
final editableContext = tester.element(find.byType(EditableText));
final before = controller.buildTextSpan(
context: editableContext,
style: textField.style,
withComposing: true,
);
controller.selection = const TextSelection(
baseOffset: 4,
extentOffset: 7,
);
final after = controller.buildTextSpan(
context: editableContext,
style: textField.style,
withComposing: true,
);
expect(after, same(before));
});
testWidgets('native All Photos picker failures show an error', (
tester,
) async {
@@ -2438,8 +2797,17 @@ void main() {
)
as SystemContextMenu;
final pasteImage = menu.items.first as IOSSystemContextMenuItemCustom;
final rebuiltMenu =
textField.contextMenuBuilder!(
tester.element(find.byType(TextField)),
editableTextState,
)
as SystemContextMenu;
final rebuiltPasteImage =
rebuiltMenu.items.first as IOSSystemContextMenuItemCustom;
expect(pasteImage.title, 'Paste Image');
expect(rebuiltPasteImage.onPressed, same(pasteImage.onPressed));
expect(menu.items.skip(1), orderedEquals(defaultItems));
pasteImage.onPressed();
await tester.pumpAndSettle();
@@ -3433,6 +3801,29 @@ void main() {
expect(controller.selection.baseOffset, 13); // after "@Alice "
});
test('updates text and selection in one editor notification', () {
final controller = TextEditingController(text: '@ali');
controller.selection = const TextSelection.collapsed(offset: 4);
var notifications = 0;
controller.addListener(() => notifications += 1);
spliceAndMoveCursor(
controller,
FocusNode(),
start: 0,
replacement: '@Alice ',
);
expect(notifications, 1);
expect(
controller.value,
const TextEditingValue(
text: '@Alice ',
selection: TextSelection.collapsed(offset: 7),
),
);
});
test('replaces #channel query with channel name', () {
final controller = TextEditingController(text: 'see #gen for details');
controller.selection = const TextSelection.collapsed(offset: 8);

View File

@@ -0,0 +1,448 @@
import 'dart:async';
import 'package:buzz/features/invites/invite_create_page.dart';
import 'package:buzz/features/invites/invite_create_provider.dart';
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
const owner =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const alice =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
testWidgets('owner can invite a pasted npub and share generated link', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions();
String? sharedUrl;
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.owner),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [
CommunityMember(pubkey: owner, role: CommunityMemberRole.owner),
],
),
),
myPubkeyProvider.overrideWithValue(owner),
communityInviteDirectoryProvider.overrideWith(
(ref) async => const [
CommunityInviteDirectoryUser(
pubkey: alice,
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
],
),
communityInviteDirectorySearchProvider.overrideWith(
(ref, query) async => const [],
),
communityInviteProfileProvider.overrideWith(
(ref, pubkey) async => CommunityInviteDirectoryUser(
pubkey: pubkey,
displayName: 'Resolved person',
nip05Handle: 'person@example.com',
),
),
communityInviteActionsProvider.overrideWithValue(actions),
shareCommunityInviteProvider.overrideWithValue((url, origin) async {
sharedUrl = url;
}),
],
child: MaterialApp(
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
expect(actions.mintRequests, [(defaultCommunityInviteTtlSeconds, null)]);
expect(
find.text('Add someone directly or share a link they can use to join.'),
findsNothing,
);
expect(find.text('Invite people'), findsNothing);
expect(find.text('Search by name or paste an npub.'), findsNothing);
expect(find.text('Alice'), findsNothing);
expect(
find.byKey(const Key('community-invite-person-$alice')),
findsNothing,
);
expect(find.text('https://relay.example.com/invite/1'), findsNothing);
expect(find.text('Invite link ready'), findsNothing);
expect(find.text('Invite link'), findsNothing);
expect(find.text('Creating invite link…'), findsNothing);
expect(find.text('Or use npub'), findsOneWidget);
expect(find.text('Search npub'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Share'), findsOneWidget);
expect(find.text('Messages'), findsNothing);
expect(find.text('Mail'), findsNothing);
expect(find.text('WhatsApp'), findsNothing);
expect(find.text('Gmail'), findsNothing);
expect(find.text('Telegram'), findsNothing);
expect(find.text('X'), findsNothing);
expect(
tester.getSize(find.byKey(const Key('community-invite-search'))).width,
greaterThan(350),
);
final shareSize = tester.getSize(
find.byKey(const Key('community-invite-share-link')),
);
final copySize = tester.getSize(
find.byKey(const Key('community-invite-copy-link')),
);
expect(shareSize, copySize);
expect(shareSize.height, 68 + (Grid.xxs * 2));
expect(
tester
.getTopLeft(find.byKey(const Key('community-invite-share-link')))
.dx,
lessThan(
tester
.getTopLeft(find.byKey(const Key('community-invite-copy-link')))
.dx,
),
);
expect(tester.widget<Icon>(find.byIcon(LucideIcons.share2)).size, 22);
expect(tester.widget<Icon>(find.byIcon(LucideIcons.copy)).size, 22);
final settingsDivider = find.descendant(
of: find.byKey(const Key('community-invite-link-settings')),
matching: find.byType(Divider),
);
final divider = tester.widget<Divider>(settingsDivider);
expect(divider.indent, Grid.xs);
expect(divider.endIndent, Grid.xs);
expect(
tester.getRect(settingsDivider).left + divider.indent!,
closeTo(tester.getRect(find.text('Expires after')).left, 0.5),
);
const pastedPubkey =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pastedPubkey,
);
await tester.enterText(
find.byKey(const Key('community-invite-search')),
npub,
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('community-invite-resolved-$pastedPubkey')),
findsOneWidget,
);
expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget);
expect(find.text('Resolved person'), findsNothing);
expect(find.text('person@example.com'), findsNothing);
expect(
find.byKey(const Key('community-invite-person-$pastedPubkey')),
findsNothing,
);
expect(find.text('Role'), findsOneWidget);
expect(find.byIcon(LucideIcons.shield), findsNothing);
final personDivider = find.descendant(
of: find.byKey(const Key('community-invite-person-card')),
matching: find.byType(Divider),
);
expect(personDivider, findsOneWidget);
final personDividerWidget = tester.widget<Divider>(personDivider);
expect(personDividerWidget.indent, Grid.xs);
expect(personDividerWidget.endIndent, Grid.xs);
await tester.tap(find.byKey(const Key('community-invite-submit')));
await tester.pumpAndSettle();
expect(actions.memberInvites, hasLength(1));
expect(actions.memberInvites.single.$1, [pastedPubkey]);
expect(actions.memberInvites.single.$2, CommunityMemberRole.member);
await tester.ensureVisible(
find.byKey(const Key('community-invite-share-link')),
);
await tester.tap(find.byKey(const Key('community-invite-share-link')));
await tester.pumpAndSettle();
expect(sharedUrl, 'https://relay.example.com/invite/1');
await tester.ensureVisible(
find.byKey(const Key('community-invite-copy-link')),
);
await tester.tap(find.byKey(const Key('community-invite-copy-link')));
await tester.pumpAndSettle();
expect(
hapticCalls.map((call) => call.arguments),
everyElement('HapticFeedbackType.lightImpact'),
);
expect(hapticCalls, hasLength(2));
});
testWidgets('pasted npub remains inviteable without profile metadata', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions();
final profileResolution = Completer<CommunityInviteDirectoryUser?>();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [],
),
),
myPubkeyProvider.overrideWithValue(owner),
communityInviteProfileProvider.overrideWith(
(ref, pubkey) => profileResolution.future,
),
communityInviteActionsProvider.overrideWithValue(actions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
const pastedPubkey =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pastedPubkey,
);
await tester.enterText(
find.byKey(const Key('community-invite-search')),
npub,
);
await tester.pump();
expect(
find.byKey(const Key('community-invite-resolving-$pastedPubkey')),
findsOneWidget,
);
expect(find.byKey(const Key('community-invite-submit')), findsNothing);
expect(actions.memberInvites, isEmpty);
profileResolution.complete(
const CommunityInviteDirectoryUser(pubkey: pastedPubkey),
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('community-invite-resolved-$pastedPubkey')),
findsOneWidget,
);
expect(find.text('Profile not found'), findsNothing);
expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget);
expect(find.byKey(const Key('community-invite-submit')), findsOneWidget);
await tester.tap(find.byKey(const Key('community-invite-submit')));
await tester.pumpAndSettle();
expect(actions.memberInvites, hasLength(1));
expect(actions.memberInvites.single.$1, [pastedPubkey]);
expect(actions.memberInvites.single.$2, CommunityMemberRole.member);
});
testWidgets('invite completion does not update state after page disposal', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions()
..memberInviteCompleter = Completer<void>();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [],
),
),
myPubkeyProvider.overrideWithValue(owner),
communityInviteProfileProvider.overrideWith(
(ref, pubkey) async => CommunityInviteDirectoryUser(pubkey: pubkey),
),
communityInviteActionsProvider.overrideWithValue(actions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
const pastedPubkey =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pastedPubkey,
);
await tester.enterText(
find.byKey(const Key('community-invite-search')),
npub,
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('community-invite-submit')));
await tester.pump();
await tester.pumpWidget(const SizedBox.shrink());
actions.memberInviteCompleter!.complete();
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('link settings remint with desktop expiry and use options', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [],
),
),
communityInviteDirectoryProvider.overrideWith(
(ref) async => const [],
),
communityInviteActionsProvider.overrideWithValue(actions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('community-invite-max-uses-setting')),
);
await tester.pumpAndSettle();
expect(find.text('Limit number of uses'), findsNWidgets(2));
expect(find.byIcon(LucideIcons.check), findsOneWidget);
await tester.tap(find.byKey(const Key('community-invite-option-5-uses')));
await tester.pumpAndSettle();
expect(actions.mintRequests, [
(defaultCommunityInviteTtlSeconds, null),
(defaultCommunityInviteTtlSeconds, 5),
]);
await tester.tap(find.byKey(const Key('community-invite-expiry-setting')));
await tester.pumpAndSettle();
expect(find.text('Expires after'), findsNWidgets(2));
expect(find.byIcon(LucideIcons.check), findsOneWidget);
await tester.tap(find.byKey(const Key('community-invite-option-7-days')));
await tester.pumpAndSettle();
expect(actions.mintRequests.last, (7 * 24 * 60 * 60, 5));
});
testWidgets('plain members cannot open invite tools', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.member),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite access required'), findsOneWidget);
expect(find.byKey(const Key('community-invite-search')), findsNothing);
});
}
class _FakeInviteActions implements CommunityInviteActions {
final List<(int, int?)> mintRequests = [];
final List<(List<String>, CommunityMemberRole)> memberInvites = [];
Completer<void>? memberInviteCompleter;
@override
Future<void> inviteMembers({
required Iterable<String> pubkeys,
required CommunityMemberRole role,
}) async {
memberInvites.add((pubkeys.toList(), role));
await memberInviteCompleter?.future;
}
@override
Future<MintedCommunityInvite> mintInvite({
required int ttlSeconds,
required int? maxUses,
}) async {
mintRequests.add((ttlSeconds, maxUses));
return MintedCommunityInvite(
code: '${mintRequests.length}',
expiresAt: 12345,
url: 'https://relay.example.com/invite/${mintRequests.length}',
maxUses: maxUses,
usesRemaining: maxUses,
);
}
}

View File

@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:buzz/features/invites/invite_create_provider.dart';
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
void main() {
test('accepts hex and npub inputs but rejects other NIP-19 values', () {
final keys = nostr.Keys.generate();
expect(parseCommunityInvitePubkey(keys.public), keys.public);
expect(parseCommunityInvitePubkey(keys.public.toUpperCase()), keys.public);
expect(parseCommunityInvitePubkey(keys.npub), keys.public);
expect(parseCommunityInvitePubkey(keys.nsec), isNull);
expect(parseCommunityInvitePubkey('not-a-pubkey'), isNull);
});
test('builds the desktop-compatible kind:9030 role tags', () {
const pubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
expect(
buildCommunityMemberInviteTags(
pubkey: pubkey.toUpperCase(),
role: CommunityMemberRole.admin,
),
[
['p', pubkey],
['role', 'admin'],
],
);
});
test(
'valid npub falls back to its pubkey when profile metadata is absent',
() async {
final keys = nostr.Keys.generate();
final session = _ProfileRelaySession(const []);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(_TestRelayConfigNotifier.new),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final invitee = await container.read(
communityInviteProfileProvider(keys.npub).future,
);
expect(invitee?.pubkey, keys.public);
expect(invitee?.displayName, isNull);
expect(session.queryCount, 1);
},
);
test('mints an unlimited invite with exact NIP-98 request shape', () async {
final keys = nostr.Keys.generate();
late http.Request captured;
final client = http_testing.MockClient((request) async {
captured = request;
return http.Response(
jsonEncode({
'code': 'invite-code',
'expires_at': 12345,
'url': 'https://relay.example.com/invite/invite-code',
'max_uses': null,
'uses_remaining': null,
}),
200,
);
});
final service = RelayCommunityInviteActions(
httpClient: client,
baseUrl: 'https://relay.example.com',
nsec: keys.nsec,
signedEventRelay: SignedEventRelay(
session: _UnusedRelaySession(),
nsec: keys.nsec,
),
isCommunityActive: () => true,
);
final invite = await service.mintInvite(
ttlSeconds: defaultCommunityInviteTtlSeconds,
maxUses: null,
);
expect(captured.method, 'POST');
expect(captured.url, Uri.parse('https://relay.example.com/api/invites'));
expect(captured.headers['content-type'], 'application/json');
expect(captured.headers['authorization'], startsWith('Nostr '));
expect(jsonDecode(captured.body), {
'ttl_secs': defaultCommunityInviteTtlSeconds,
});
expect(invite.code, 'invite-code');
expect(invite.maxUses, isNull);
expect(invite.usesRemaining, isNull);
});
test('includes a selected maximum-use limit', () async {
final keys = nostr.Keys.generate();
late http.Request captured;
final client = http_testing.MockClient((request) async {
captured = request;
return http.Response(
jsonEncode({
'code': 'limited',
'expires_at': 12345,
'url': 'https://relay.example.com/invite/limited',
'max_uses': 5,
'uses_remaining': 5,
}),
200,
);
});
final service = RelayCommunityInviteActions(
httpClient: client,
baseUrl: 'https://relay.example.com',
nsec: keys.nsec,
signedEventRelay: SignedEventRelay(
session: _UnusedRelaySession(),
nsec: keys.nsec,
),
isCommunityActive: () => true,
);
await service.mintInvite(ttlSeconds: 86400, maxUses: 5);
expect(jsonDecode(captured.body), {'ttl_secs': 86400, 'max_uses': 5});
});
}
class _UnusedRelaySession extends RelaySessionNotifier {}
class _TestRelayConfigNotifier extends RelayConfigNotifier {
@override
RelayConfig build() =>
const RelayConfig(baseUrl: 'https://relay.example.com');
}
class _ProfileRelaySession extends RelaySessionNotifier {
_ProfileRelaySession(this.events);
final List<NostrEvent> events;
int queryCount = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
queryCount++;
return events;
}
}

View File

@@ -0,0 +1,109 @@
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:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
testWidgets('shows community invite navigation to owners and admins', (
tester,
) async {
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.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsOneWidget);
expect(
find.text('Add people directly or share an invite link'),
findsNothing,
);
await tester.tap(find.text('Invite to community'));
await tester.pumpAndSettle();
expect(find.text('Invite destination'), findsOneWidget);
});
testWidgets('keeps invite navigation available when role lookup fails', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
currentCommunityRoleProvider.overrideWithValue(
AsyncError<CommunityMemberRole?>(
Exception('membership query failed'),
StackTrace.empty,
),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsOneWidget);
await tester.tap(find.text('Invite to community'));
await tester.pumpAndSettle();
expect(find.text('Invite destination'), findsOneWidget);
});
testWidgets('hides community invite navigation from plain members', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.member),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsNothing);
});
}

View File

@@ -174,6 +174,7 @@ void main() {
tester,
SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4},
@@ -189,6 +190,7 @@ void main() {
tester,
SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
prefs: {

View File

@@ -0,0 +1,72 @@
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const owner =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const admin =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const member =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
test('parses Buzz and legacy membership tags with roles', () {
final snapshot = communityMembershipFromEvents([
_event(
createdAt: 2,
tags: [
['member', owner, 'owner'],
['member', admin.toUpperCase(), 'admin'],
['p', member, 'wss://relay.example.com', 'member'],
['member', 'not-a-pubkey', 'owner'],
['member', owner, 'member'],
],
),
]);
expect(snapshot.snapshotFound, isTrue);
expect(snapshot.members, hasLength(3));
expect(snapshot.roleFor(owner), CommunityMemberRole.owner);
expect(snapshot.roleFor(admin), CommunityMemberRole.admin);
expect(snapshot.roleFor(member), CommunityMemberRole.member);
expect(snapshot.pubkeys, {owner, admin, member});
});
test('uses the latest membership snapshot', () {
final snapshot = communityMembershipFromEvents([
_event(
createdAt: 1,
tags: [
['member', owner, 'owner'],
],
),
_event(
createdAt: 2,
tags: [
['member', owner, 'admin'],
],
),
]);
expect(snapshot.roleFor(owner), CommunityMemberRole.admin);
});
test('fails closed when no snapshot is available', () {
final snapshot = communityMembershipFromEvents(const []);
expect(snapshot.snapshotFound, isFalse);
expect(snapshot.members, isEmpty);
expect(canManageCommunityInvites(snapshot.roleFor(owner)), isFalse);
});
}
NostrEvent _event({required int createdAt, required List<List<String>> tags}) =>
NostrEvent(
id: '$createdAt',
pubkey: 'relay',
createdAt: createdAt,
kind: EventKind.relayMembership,
tags: tags,
content: '',
sig: '',
);