mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: Support MeshCore message links #123
This commit is contained in:
@@ -737,8 +737,15 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (message.isContactMessage) {
|
||||
// Match by sender key + sender timestamp (matches official app's DB
|
||||
// uniqueness: contactPublicKey + senderTimestamp + text + txtType).
|
||||
return existing.senderKeyShort == message.senderKeyShort &&
|
||||
existing.senderTimestamp == message.senderTimestamp;
|
||||
if (existing.senderKeyShort == message.senderKeyShort &&
|
||||
existing.senderTimestamp == message.senderTimestamp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback dedup when retransmits surface as separate inbound rows
|
||||
// without a stable timestamp/message id, but still carry the same
|
||||
// visible sender identity and payload.
|
||||
return _matchesDuplicateSenderIdentity(existing, message);
|
||||
}
|
||||
|
||||
if (message.isChannelMessage) {
|
||||
@@ -760,6 +767,14 @@ class MessagesProvider with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _matchesDuplicateSenderIdentity(existing, message);
|
||||
}
|
||||
|
||||
// System messages and other types: never deduplicate by scope alone.
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _matchesDuplicateSenderIdentity(Message existing, Message message) {
|
||||
final existingSenderKey = existing.senderKeyShort;
|
||||
final incomingSenderKey = message.senderKeyShort;
|
||||
if (existingSenderKey != null &&
|
||||
@@ -770,17 +785,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
final existingSenderName = _normalizedResolvedSenderName(existing);
|
||||
final incomingSenderName = _normalizedResolvedSenderName(message);
|
||||
if (existingSenderName != null &&
|
||||
return existingSenderName != null &&
|
||||
incomingSenderName != null &&
|
||||
existingSenderName == incomingSenderName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// System messages and other types: never deduplicate by scope alone.
|
||||
return false;
|
||||
existingSenderName == incomingSenderName;
|
||||
}
|
||||
|
||||
/// Add multiple messages
|
||||
|
||||
@@ -6,7 +6,9 @@ import '../providers/connection_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class AddContactScreen extends StatefulWidget {
|
||||
const AddContactScreen({super.key});
|
||||
final String? initialAdvert;
|
||||
|
||||
const AddContactScreen({super.key, this.initialAdvert});
|
||||
|
||||
@override
|
||||
State<AddContactScreen> createState() => _AddContactScreenState();
|
||||
@@ -21,8 +23,13 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialAdvert != null &&
|
||||
widget.initialAdvert!.trim().isNotEmpty) {
|
||||
_advertController.text = widget.initialAdvert!.trim();
|
||||
} else {
|
||||
_loadClipboardIfPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -79,9 +86,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
||||
final text = clipboardData?.text;
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -140,9 +147,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)),
|
||||
);
|
||||
setState(() {
|
||||
_importSucceeded = true;
|
||||
});
|
||||
|
||||
@@ -667,7 +667,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
|
||||
try {
|
||||
// Check destination type and send accordingly
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../models/sar_marker.dart';
|
||||
@@ -31,6 +33,7 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../../utils/log_rx_route_decoder.dart';
|
||||
import '../../models/message_transfer_details.dart';
|
||||
import '../../screens/add_contact_screen.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
import 'tictactoe_message_bubble.dart';
|
||||
@@ -72,9 +75,28 @@ class MessageBubble extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MessageBubbleState extends State<MessageBubble> {
|
||||
static const int _meshcoreAdvertMinBytes = 98;
|
||||
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
|
||||
static final RegExp _linkPattern = RegExp(
|
||||
r'(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _rawMeshcoreAdvertPattern = RegExp(
|
||||
r'(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
|
||||
);
|
||||
static final RegExp _messageTokenPattern = RegExp(
|
||||
r'@\[(.+?)\]|(?:(?:https?:\/\/)|(?:www\.)|(?:meshcore:\/\/))[^\s<]+|(?<![0-9a-fA-F])[0-9a-fA-F]{196,}(?![0-9a-fA-F])',
|
||||
caseSensitive: false,
|
||||
);
|
||||
bool _isExpanded = false;
|
||||
bool _showReceivedStats = false;
|
||||
final List<TapGestureRecognizer> _linkRecognizers = [];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeLinkRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MessageBubble oldWidget) {
|
||||
@@ -100,14 +122,108 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
});
|
||||
}
|
||||
|
||||
void _disposeLinkRecognizers() {
|
||||
for (final recognizer in _linkRecognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_linkRecognizers.clear();
|
||||
}
|
||||
|
||||
String _trimTrailingUrlPunctuation(String value) {
|
||||
var trimmed = value;
|
||||
while (trimmed.isNotEmpty) {
|
||||
final lastChar = trimmed[trimmed.length - 1];
|
||||
final shouldTrim = switch (lastChar) {
|
||||
'.' || ',' || '!' || '?' || ':' || ';' => true,
|
||||
')' => '('.allMatches(trimmed).length < ')'.allMatches(trimmed).length,
|
||||
']' => '['.allMatches(trimmed).length < ']'.allMatches(trimmed).length,
|
||||
'}' => '{'.allMatches(trimmed).length < '}'.allMatches(trimmed).length,
|
||||
_ => false,
|
||||
};
|
||||
if (!shouldTrim) {
|
||||
break;
|
||||
}
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
Uri? _parseMessageLink(String value) {
|
||||
final trimmed = _trimTrailingUrlPunctuation(value);
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_isRawMeshcoreAdvert(trimmed)) {
|
||||
return Uri.parse('meshcore://$trimmed');
|
||||
}
|
||||
|
||||
final normalized = trimmed.startsWith('www.')
|
||||
? 'https://$trimmed'
|
||||
: trimmed;
|
||||
final uri = Uri.tryParse(normalized);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
bool _isRawMeshcoreAdvert(String value) {
|
||||
if (!_rawMeshcoreAdvertPattern.hasMatch(value)) {
|
||||
return false;
|
||||
}
|
||||
return value.length.isEven && value.length >= _meshcoreAdvertMinBytes * 2;
|
||||
}
|
||||
|
||||
Future<void> _openMessageLink(String rawUrl) async {
|
||||
final uri = _parseMessageLink(rawUrl);
|
||||
if (uri == null) {
|
||||
ToastLogger.error(context, 'Invalid link');
|
||||
return;
|
||||
}
|
||||
|
||||
if (uri.scheme.toLowerCase() == 'meshcore') {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AddContactScreen(initialAdvert: rawUrl),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!await canLaunchUrl(uri)) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ToastLogger.error(context, 'Cannot open link');
|
||||
return;
|
||||
}
|
||||
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ToastLogger.error(context, 'Failed to open link');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
|
||||
final matches = _mentionPattern.allMatches(text).toList();
|
||||
_disposeLinkRecognizers();
|
||||
|
||||
final matches = _messageTokenPattern.allMatches(text).toList();
|
||||
if (matches.isEmpty) {
|
||||
return Text(text, style: baseBodyStyle);
|
||||
}
|
||||
|
||||
final textColor =
|
||||
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
|
||||
final linkColor = Theme.of(context).colorScheme.primary;
|
||||
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
|
||||
final backgroundColor = Theme.of(
|
||||
context,
|
||||
@@ -129,8 +245,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
final rawMatch = match.group(0) ?? '';
|
||||
final mentionName = match.group(1)?.trim() ?? '';
|
||||
if (mentionName.isNotEmpty) {
|
||||
if (_mentionPattern.hasMatch(rawMatch) && mentionName.isNotEmpty) {
|
||||
spans.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
@@ -154,8 +271,34 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (_linkPattern.hasMatch(rawMatch) ||
|
||||
_isRawMeshcoreAdvert(rawMatch)) {
|
||||
final trimmedUrl = _trimTrailingUrlPunctuation(rawMatch);
|
||||
final trailingText = rawMatch.substring(trimmedUrl.length);
|
||||
final uri = _parseMessageLink(rawMatch);
|
||||
if (uri != null) {
|
||||
final recognizer = TapGestureRecognizer()
|
||||
..onTap = () => _openMessageLink(rawMatch);
|
||||
_linkRecognizers.add(recognizer);
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: trimmedUrl,
|
||||
style: baseBodyStyle?.copyWith(
|
||||
color: linkColor,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
recognizer: recognizer,
|
||||
),
|
||||
);
|
||||
if (trailingText.isNotEmpty) {
|
||||
spans.add(TextSpan(text: trailingText, style: baseBodyStyle));
|
||||
}
|
||||
} else {
|
||||
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle));
|
||||
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
|
||||
}
|
||||
} else {
|
||||
spans.add(TextSpan(text: rawMatch, style: baseBodyStyle));
|
||||
}
|
||||
|
||||
cursor = match.end;
|
||||
|
||||
@@ -390,6 +390,38 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('incoming contact messages dedupe by sender handle and text', () {
|
||||
final provider = MessagesProvider();
|
||||
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'handle-1',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000800,
|
||||
text: 'same payload',
|
||||
senderName: 'Radio Alpha',
|
||||
receivedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'handle-2',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 2,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000810,
|
||||
text: 'same payload',
|
||||
senderName: 'Radio Alpha',
|
||||
receivedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(provider.messages, hasLength(1));
|
||||
expect(provider.messages.single.id, equals('handle-1'));
|
||||
});
|
||||
|
||||
test('display list collapses stored duplicates and sums copy counts', () {
|
||||
final provider = MessagesProvider();
|
||||
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
@@ -22,8 +23,36 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final launchedUrls = <String>[];
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
launchedUrls.clear();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/url_launcher'),
|
||||
(call) async {
|
||||
switch (call.method) {
|
||||
case 'canLaunch':
|
||||
return true;
|
||||
case 'launch':
|
||||
final arguments = Map<dynamic, dynamic>.from(
|
||||
call.arguments as Map<dynamic, dynamic>,
|
||||
);
|
||||
launchedUrls.add(arguments['url'] as String);
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/url_launcher'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('received bubbles show signal chips on double tap', (
|
||||
@@ -153,6 +182,151 @@ void main() {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('message bubble detects and opens links', (tester) async {
|
||||
final harness = await _TestHarness.create();
|
||||
try {
|
||||
final message = Message(
|
||||
id: 'message-link',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: _prefix(31),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'Check https://example.com/docs, then ping @[Rescue Team].',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||
deliveryStatus: MessageDeliveryStatus.received,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(harness, message));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final richText = tester
|
||||
.widgetList<RichText>(find.byType(RichText))
|
||||
.firstWhere(
|
||||
(widget) =>
|
||||
widget.text.toPlainText().contains('https://example.com/docs'),
|
||||
);
|
||||
final linkSpan = _findTextSpan(
|
||||
richText.text,
|
||||
(span) => span.text == 'https://example.com/docs',
|
||||
);
|
||||
|
||||
expect(richText.text.toPlainText(), contains('https://example.com/docs'));
|
||||
expect(find.text('@Rescue Team'), findsOneWidget);
|
||||
|
||||
expect(linkSpan, isNotNull);
|
||||
|
||||
final recognizer = linkSpan!.recognizer;
|
||||
expect(recognizer, isA<TapGestureRecognizer>());
|
||||
(recognizer! as TapGestureRecognizer).onTap!();
|
||||
await tester.pump();
|
||||
|
||||
expect(launchedUrls, ['https://example.com/docs']);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('message bubble opens meshcore links in add contact screen', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = await _TestHarness.create();
|
||||
try {
|
||||
const payloadSegment = '00112233445566778899aabbccddeeff';
|
||||
final advert =
|
||||
'meshcore://'
|
||||
'$payloadSegment'
|
||||
'$payloadSegment'
|
||||
'$payloadSegment'
|
||||
'$payloadSegment'
|
||||
'$payloadSegment'
|
||||
'$payloadSegment'
|
||||
'0011';
|
||||
final message = Message(
|
||||
id: 'message-meshcore-link',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: _prefix(41),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'Import $advert',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||
deliveryStatus: MessageDeliveryStatus.received,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(harness, message));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final richText = tester
|
||||
.widgetList<RichText>(find.byType(RichText))
|
||||
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
|
||||
final linkSpan = _findTextSpan(
|
||||
richText.text,
|
||||
(span) => span.text == advert,
|
||||
);
|
||||
|
||||
expect(linkSpan, isNotNull);
|
||||
|
||||
final recognizer = linkSpan!.recognizer;
|
||||
expect(recognizer, isA<TapGestureRecognizer>());
|
||||
(recognizer! as TapGestureRecognizer).onTap!();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Import a shared contact advert'), findsOneWidget);
|
||||
expect(find.text(advert), findsOneWidget);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('message bubble linkifies raw meshcore adverts', (tester) async {
|
||||
final harness = await _TestHarness.create();
|
||||
try {
|
||||
const advert =
|
||||
'00112233445566778899aabbccddeeff'
|
||||
'00112233445566778899aabbccddeeff'
|
||||
'00112233445566778899aabbccddeeff'
|
||||
'00112233445566778899aabbccddeeff'
|
||||
'00112233445566778899aabbccddeeff'
|
||||
'00112233445566778899aabbccddeeff'
|
||||
'0011';
|
||||
final message = Message(
|
||||
id: 'message-meshcore-raw',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: _prefix(51),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'Import raw advert: $advert',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||
deliveryStatus: MessageDeliveryStatus.received,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildApp(harness, message));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final richText = tester
|
||||
.widgetList<RichText>(find.byType(RichText))
|
||||
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
|
||||
final linkSpan = _findTextSpan(
|
||||
richText.text,
|
||||
(span) => span.text == advert,
|
||||
);
|
||||
|
||||
expect(linkSpan, isNotNull);
|
||||
|
||||
final recognizer = linkSpan!.recognizer;
|
||||
expect(recognizer, isA<TapGestureRecognizer>());
|
||||
(recognizer! as TapGestureRecognizer).onTap!();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Import a shared contact advert'), findsOneWidget);
|
||||
expect(find.text(advert), findsOneWidget);
|
||||
} finally {
|
||||
await _disposeHarness(tester, harness);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildApp(_TestHarness harness, Message message) {
|
||||
@@ -242,3 +416,22 @@ Future<void> _doubleTap(WidgetTester tester, Finder finder) async {
|
||||
await tester.tap(finder);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
TextSpan? _findTextSpan(
|
||||
InlineSpan span,
|
||||
bool Function(TextSpan span) predicate,
|
||||
) {
|
||||
if (span is! TextSpan) {
|
||||
return null;
|
||||
}
|
||||
if (predicate(span)) {
|
||||
return span;
|
||||
}
|
||||
for (final child in span.children ?? const <InlineSpan>[]) {
|
||||
final match = _findTextSpan(child, predicate);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user