mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
166
mobile/lib/features/invites/invite_create_page.dart
Normal file
166
mobile/lib/features/invites/invite_create_page.dart
Normal 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: ', '');
|
||||
@@ -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'^-|-$'), '');
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
424
mobile/lib/features/invites/invite_create_provider.dart
Normal file
424
mobile/lib/features/invites/invite_create_provider.dart
Normal 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),
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
127
mobile/lib/shared/community/community_membership_provider.dart
Normal file
127
mobile/lib/shared/community/community_membership_provider.dart
Normal 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;
|
||||
@@ -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() =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
84
mobile/lib/shared/widgets/buzz_action_tile.dart
Normal file
84
mobile/lib/shared/widgets/buzz_action_tile.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
448
mobile/test/features/invites/invite_create_page_test.dart
Normal file
448
mobile/test/features/invites/invite_create_page_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
164
mobile/test/features/invites/invite_create_provider_test.dart
Normal file
164
mobile/test/features/invites/invite_create_provider_test.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
109
mobile/test/features/settings/settings_page_test.dart
Normal file
109
mobile/test/features/settings/settings_page_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: '',
|
||||
);
|
||||
Reference in New Issue
Block a user