Files
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

456 lines
14 KiB
Dart

part of '../compose_bar.dart';
const _typingThrottleMs = 3000;
class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver {
final FlutterView view;
final VoidCallback onKeyboardHidden;
bool _wasVisible;
_ComposerKeyboardMetricsObserver({
required this.view,
required this.onKeyboardHidden,
}) : _wasVisible = view.viewInsets.bottom > 0;
@override
void didChangeMetrics() {
final isVisible = view.viewInsets.bottom > 0;
if (_wasVisible && !isVisible) onKeyboardHidden();
_wasVisible = isVisible;
}
}
void _runComposerAction(VoidCallback action) {
unawaited(HapticFeedback.selectionClick());
action();
}
void _showComposerEmojiPicker(
BuildContext context,
ValueChanged<String> onSelect,
VoidCallback onDismiss,
) {
showEmojiPicker(
context: context,
onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)),
onDismiss: onDismiss,
);
}
void _dismissComposerKeyboard(FocusNode focusNode) {
focusNode.unfocus();
unawaited(SystemChannels.textInput.invokeMethod<void>('TextInput.hide'));
}
const _pastedImageMimeTypes = <String>[
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
];
/// Cap on ranked mention suggestions shown — matches desktop's
/// `MENTION_SUGGESTION_LIMIT`.
const _mentionSuggestionLimit = 50;
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
/// word boundary. Returns the index of the trigger character, or `null` if none
/// is found.
///
/// When [stopAtSpace] is `true` the walk stops at both spaces and newlines —
/// appropriate for `#channel` names which are kebab-case slugs without spaces.
/// When `false`, only newlines stop the walk, allowing multi-word queries like
/// `@Alice Smith` to match members with multi-word display names.
@visibleForTesting
int? findTrigger(
String text,
int cursor,
String trigger, {
bool stopAtSpace = true,
}) {
for (var i = cursor - 1; i >= 0; i--) {
final ch = text[i];
if (ch == '\n') break;
if (stopAtSpace && ch == ' ') break;
if (ch == trigger) {
if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '\n') {
return i;
}
break;
}
}
return null;
}
/// Replace the range `[start, cursor)` with [replacement] and move the cursor
/// to the end of the replacement. Used by both mention and channel insertion.
@visibleForTesting
void spliceAndMoveCursor(
TextEditingController controller,
FocusNode focusNode, {
required int start,
required String replacement,
}) {
final text = controller.text;
final cursor =
(controller.selection.isValid
? controller.selection.baseOffset
: text.length)
.clamp(start, text.length);
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
);
focusNode.requestFocus();
}
/// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with
/// a space if needed for word separation. Used by `triggerMention` and
/// `triggerChannel`.
void _insertTriggerAtCursor(
TextEditingController controller,
FocusNode focusNode,
String trigger,
) {
final text = controller.text;
final cursor = controller.selection.isValid
? controller.selection.baseOffset
: text.length;
final needsSpace =
cursor > 0 && text[cursor - 1] != ' ' && text[cursor - 1] != '\n';
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,
);
focusNode.requestFocus();
}
bool hasMention(String text, String name) {
final pattern = RegExp(
'(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)',
caseSensitive: false,
);
return pattern.hasMatch(text);
}
/// Outcome of the non-member mention prompt. `null` (dialog dismissed)
/// cancels the send and keeps the draft.
enum _NonMemberMentionChoice { invite, sendWithoutInviting }
/// User-facing text for a failed add or send.
String _composeSendErrorMessage(Object error) {
if (error is AddMembersException) return error.message;
return error.toString().replaceFirst('Exception: ', '');
}
/// Ask whether to invite mentioned humans who aren't channel members, or
/// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`.
/// [canInvite] false (a private channel the sender doesn't own/administer)
/// drops the Invite action — the relay rejects that add, so offering it would
/// only produce an error.
Future<_NonMemberMentionChoice?> _promptNonMemberMention(
BuildContext context, {
required List<String> names,
required bool canInvite,
}) {
return showBuzzDialog<_NonMemberMentionChoice>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.mentionOutsideTitle),
content: Text(
canInvite
? context.l10n.mentionOutsidePeopleContent(names.join(', '))
: '${context.l10n.mentionOutsidePeopleContent(names.join(', '))} '
'${context.l10n.privateChannelNoInvite}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(
dialogContext,
).pop(_NonMemberMentionChoice.sendWithoutInviting),
child: Text(
canInvite ? context.l10n.doNothing : context.l10n.sendAnyway,
),
),
if (canInvite)
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite),
child: Text(context.l10n.invite),
),
],
),
);
}
/// Send a typing indicator over the WebSocket (fire-and-forget).
///
/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not
/// via HTTP. Ephemeral events like typing indicators are broadcast-only and
/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may
/// silently discard them.
void _sendTypingIndicator(
WidgetRef ref, {
required String channelId,
String? threadHeadId,
String? rootId,
}) {
try {
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) return;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return;
final tags = <List<String>>[
['h', channelId],
if (threadHeadId != null && rootId != null && rootId != threadHeadId) ...[
['e', rootId, '', 'root'],
['e', threadHeadId, '', 'reply'],
] else if (threadHeadId != null)
['e', threadHeadId, '', 'reply'],
];
final event = nostr.Event.from(
kind: EventKind.typingIndicator,
content: '',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
// Send directly over WebSocket — fire-and-forget, matching desktop.
final session = ref.read(relaySessionProvider.notifier);
session.sendRaw(['EVENT', event.toMap()]);
} catch (_) {
// Fire-and-forget — typing indicator failure is non-fatal.
}
}
/// Reports a send that was cancelled because the active community changed.
///
/// The send path is fire-and-forget, so a `StateError` escaping it would be
/// silent. The composer's own error line cannot carry this message either: the
/// identity change that causes the failure also resets that state on the next
/// frame, so [messenger] must be resolved before the send's first `await`.
void _reportSendCancelledByCommunitySwitch(
ScaffoldMessengerState? messenger,
String message,
) {
messenger?.showSnackBar(
SnackBar(content: Text(message)),
);
}
/// What an add attempt left undone: who is still a non-member, and why.
@immutable
class _NonMemberAddOutcome {
final List<String> notAdded;
final List<String> errors;
const _NonMemberAddOutcome({required this.notAdded, required this.errors});
static const empty = _NonMemberAddOutcome(notAdded: [], errors: []);
}
/// Adds mentioned non-members to the channel before a send.
///
/// Agents are added silently with the `bot` role; humans are only passed here
/// after they have been explicitly invited from the mention prompt.
///
/// A rejection is reported, never thrown: the send is fire-and-forget, so an
/// escaping error would drop the message with nothing shown. [StateError] still
/// propagates — a community switch must cancel the whole send.
Future<_NonMemberAddOutcome> _addMentionedNonMembers(
ChannelActions channelActions, {
required String channelId,
required List<String> agentPubkeys,
required List<String> humanPubkeys,
required bool canAddMembers,
required String privateChannelError,
}) async {
final pending = [
if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'),
if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'),
];
if (pending.isEmpty) return _NonMemberAddOutcome.empty;
// A plain member of a private channel cannot add anyone: skip the doomed
// kind:9000 rather than trading it for a relay rejection.
if (!canAddMembers) {
return _NonMemberAddOutcome(
notAdded: [for (final (pubkeys, _) in pending) ...pubkeys],
errors: [privateChannelError],
);
}
final notAdded = <String>[];
final errors = <String>[];
for (final (pubkeys, role) in pending) {
try {
await channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
);
} on StateError {
rethrow;
} catch (error) {
notAdded.addAll(
error is AddMembersException ? error.failures.keys : pubkeys,
);
errors.add(_composeSendErrorMessage(error));
}
}
return _NonMemberAddOutcome(notAdded: notAdded, errors: errors);
}
/// Mentioned identities that aren't in the channel yet, plus whether the sender
/// is allowed to add them at all.
@immutable
class _NonMemberMentionScan {
final String channelId;
final List<String> agentPubkeys;
final List<MentionCandidate> humans;
final bool canAddMembers;
const _NonMemberMentionScan({
required this.channelId,
required this.agentPubkeys,
required this.humans,
required this.canAddMembers,
});
}
/// Resolves which mentioned identities are non-members, and whether this
/// identity may add them (see [Channel.canAddMembers]). DMs are skipped: their
/// participant set is fixed at creation.
Future<_NonMemberMentionScan> _scanNonMemberMentions(
WidgetRef ref, {
required String channelId,
required List<MentionCandidate> selectedMentions,
required String? currentPubkey,
}) async {
final none = _NonMemberMentionScan(
channelId: channelId,
agentPubkeys: const [],
humans: const [],
canAddMembers: true,
);
if (selectedMentions.isEmpty) return none;
final channel = (await ref.read(
channelsProvider.future,
)).firstWhere((candidate) => candidate.id == channelId);
if (channel.isDm) return none;
final members = await ref.read(channelMembersProvider(channelId).future);
final memberPubkeys = {
for (final member in members) member.pubkey.toLowerCase(),
};
String? selfRole;
if (currentPubkey != null) {
final self = currentPubkey.toLowerCase();
for (final member in members) {
if (member.pubkey.toLowerCase() == self) {
selfRole = member.role;
break;
}
}
}
final agentPubkeys = <String>[];
final humans = <MentionCandidate>[];
final seen = <String>{};
for (final candidate in selectedMentions) {
final pubkey = candidate.pubkey.toLowerCase();
if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue;
if (candidate.isAgent) {
agentPubkeys.add(pubkey);
} else {
humans.add(candidate);
}
}
return _NonMemberMentionScan(
channelId: channelId,
agentPubkeys: agentPubkeys,
humans: humans,
canAddMembers: channel.canAddMembers(selfRole),
);
}
/// The p-tags and `mention` reference tags an outgoing message should carry.
///
/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so
/// their name still renders without notifying a non-member — mirrors desktop's
/// `mergeOutgoingTagsWithReferenceMentions`.
class _OutgoingMentions {
List<String> pubkeys;
final List<List<String>> referenceTags = [];
List<String> _invitedHumanPubkeys = const [];
_OutgoingMentions(List<MentionCandidate> selectedMentions)
: pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
void demote(Iterable<String> demoted) {
final excluded = {for (final pubkey in demoted) pubkey.toLowerCase()};
if (excluded.isEmpty) return;
pubkeys = [
for (final pubkey in pubkeys)
if (!excluded.contains(pubkey)) pubkey,
];
referenceTags.addAll([
for (final pubkey in excluded) ['mention', pubkey],
]);
}
/// Applies the mention prompt's outcome: invite them, or send without.
void resolveHumanChoice(
_NonMemberMentionChoice choice,
List<MentionCandidate> humans,
) {
final humanPubkeys = [
for (final candidate in humans) candidate.pubkey.toLowerCase(),
];
switch (choice) {
case _NonMemberMentionChoice.invite:
_invitedHumanPubkeys = humanPubkeys;
case _NonMemberMentionChoice.sendWithoutInviting:
demote(humanPubkeys);
}
}
/// Adds the scanned non-members, demoting and reporting whatever didn't land.
Future<void> addNonMembers(
ChannelActions channelActions, {
required _NonMemberMentionScan scan,
required ScaffoldMessengerState? messenger,
required String privateChannelError,
}) async {
final outcome = await _addMentionedNonMembers(
channelActions,
channelId: scan.channelId,
agentPubkeys: scan.agentPubkeys,
humanPubkeys: _invitedHumanPubkeys,
canAddMembers: scan.canAddMembers,
privateChannelError: privateChannelError,
);
demote(outcome.notAdded);
if (outcome.errors.isNotEmpty) {
messenger?.showSnackBar(
SnackBar(content: Text(outcome.errors.join(' '))),
);
}
}
}