feat: import Chinese-localized Buzz source snapshot
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
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
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(' '))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user