mirror of
https://github.com/chenasraf/ctelnet_dart.git
synced 2026-05-17 17:48:07 +00:00
feat: color parser (#3)
This commit is contained in:
@@ -15,20 +15,32 @@ void main(List<String> args) async {
|
||||
timeout: Duration(seconds: 30),
|
||||
onConnect: () => print('Connected'),
|
||||
onDisconnect: () => print('Disconnected'),
|
||||
onData: (data) {
|
||||
print('DBG: ${data.toDebugString()}');
|
||||
print('toString(): ${data.toString()}');
|
||||
print('text: ${data.text}');
|
||||
print('');
|
||||
},
|
||||
onError: (error) => print('Error: $error'),
|
||||
);
|
||||
|
||||
await client.connect();
|
||||
final sub = await client.connect();
|
||||
|
||||
if (sub == null) {
|
||||
throw Exception('Failed to connect');
|
||||
}
|
||||
|
||||
// listen to the stream of messages
|
||||
sub.listen((data) {
|
||||
print('Message received!');
|
||||
print('text: ${data.text}');
|
||||
print('Debug: ${data.toDebugString()}');
|
||||
print('Colored: ${data.coloredText.map((t) => t.formatted).join('')}');
|
||||
print('');
|
||||
});
|
||||
|
||||
// send a message
|
||||
client.send('Hello, world!');
|
||||
|
||||
// send a command
|
||||
client.doo(Symbols.compression2);
|
||||
|
||||
// await client.disconnect();
|
||||
await Future.delayed(Duration(seconds: 5));
|
||||
|
||||
await client.disconnect();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export 'src/client.dart';
|
||||
export 'src/message.dart';
|
||||
export 'src/symbols.dart';
|
||||
export 'src/builder.dart';
|
||||
export 'src/color_parser/color_parser.dart';
|
||||
|
||||
@@ -13,7 +13,7 @@ abstract class ITelnetClient {
|
||||
ConnectionStatus status = ConnectionStatus.disconnected;
|
||||
|
||||
/// Connect to the server.
|
||||
Future<void> connect();
|
||||
Future<Stream<Message>?> connect();
|
||||
|
||||
/// Send data to the server.
|
||||
int send(String data);
|
||||
@@ -47,7 +47,6 @@ class CTelnetClient implements ITelnetClient {
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
required this.onConnect,
|
||||
required this.onDisconnect,
|
||||
required this.onData,
|
||||
required this.onError,
|
||||
});
|
||||
|
||||
@@ -56,7 +55,6 @@ class CTelnetClient implements ITelnetClient {
|
||||
final Duration timeout;
|
||||
final ConnectionCallback onConnect;
|
||||
final ConnectionCallback onDisconnect;
|
||||
final DataCallback onData;
|
||||
final ErrorCallback onError;
|
||||
RawSocket? _socket;
|
||||
ConnectionTask<RawSocket>? _task;
|
||||
@@ -67,10 +65,13 @@ class CTelnetClient implements ITelnetClient {
|
||||
bool get connected => _socket != null && status == ConnectionStatus.connected;
|
||||
|
||||
StreamSubscription<RawSocketEvent>? _subscription;
|
||||
StreamController<Message>? _controller;
|
||||
|
||||
/// Connect to the host and port.
|
||||
///
|
||||
/// Returns a [Stream] to listen for [Message]s if the connection is successful.
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
Future<Stream<Message>?> connect() async {
|
||||
try {
|
||||
status = ConnectionStatus.connecting;
|
||||
final task = await RawSocket.startConnect(host, port);
|
||||
@@ -83,12 +84,16 @@ class CTelnetClient implements ITelnetClient {
|
||||
onError: _onError,
|
||||
onDone: _onDisconnect,
|
||||
);
|
||||
_controller?.close();
|
||||
_controller = StreamController<Message>();
|
||||
return _controller!.stream;
|
||||
} catch (e, stack) {
|
||||
if (!_disposed) {
|
||||
_dispose();
|
||||
_onError(e, stack);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_assertSocket([String? message]) {
|
||||
@@ -166,7 +171,7 @@ class CTelnetClient implements ITelnetClient {
|
||||
final data = _socket!.read();
|
||||
if (data != null) {
|
||||
final msg = Message(data);
|
||||
onData(msg);
|
||||
_controller!.add(msg);
|
||||
}
|
||||
break;
|
||||
case RawSocketEvent.write:
|
||||
@@ -200,6 +205,8 @@ class CTelnetClient implements ITelnetClient {
|
||||
_task = null;
|
||||
_timeoutTask?.cancel();
|
||||
_timeoutTask = null;
|
||||
_controller?.close();
|
||||
_controller = null;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -214,3 +221,4 @@ enum ConnectionStatus {
|
||||
/// The client is not connected to the server.
|
||||
disconnected,
|
||||
}
|
||||
|
||||
|
||||
78
lib/src/color_parser/base.dart
Normal file
78
lib/src/color_parser/base.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import '../consts.dart' as consts;
|
||||
|
||||
enum Token {
|
||||
esc,
|
||||
colorStart,
|
||||
colorTerm,
|
||||
colorSeparator,
|
||||
literal,
|
||||
}
|
||||
|
||||
class TokenValue {
|
||||
final Token token;
|
||||
final String raw;
|
||||
|
||||
const TokenValue(this.token, this.raw);
|
||||
|
||||
static const esc = TokenValue(Token.esc, consts.esc);
|
||||
static const colorStart = TokenValue(Token.colorStart, '[');
|
||||
static const colorSeparator = TokenValue(Token.colorSeparator, ';');
|
||||
static const colorTerm = TokenValue(Token.colorTerm, 'm');
|
||||
static const empty = TokenValue(Token.literal, '');
|
||||
|
||||
TokenValue.literal(String raw) : this(Token.literal, raw);
|
||||
|
||||
@override
|
||||
int get hashCode => raw.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TokenValue &&
|
||||
runtimeType == other.runtimeType &&
|
||||
token == other.token &&
|
||||
raw == other.raw;
|
||||
|
||||
@override
|
||||
String toString() => token != Token.esc ? '${token.name}($raw)' : token.name;
|
||||
}
|
||||
|
||||
class LexerValue {
|
||||
String text;
|
||||
int fgColor;
|
||||
int bgColor;
|
||||
|
||||
LexerValue(this.text, this.fgColor, this.bgColor);
|
||||
|
||||
@override
|
||||
int get hashCode => text.hashCode ^ fgColor.hashCode ^ bgColor.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is LexerValue &&
|
||||
runtimeType == other.runtimeType &&
|
||||
text == other.text &&
|
||||
fgColor == other.fgColor &&
|
||||
bgColor == other.bgColor;
|
||||
|
||||
@override
|
||||
String toString() => 'Lex("$text", $fgColor:$bgColor)';
|
||||
}
|
||||
|
||||
abstract class IReader<T> {
|
||||
T? read();
|
||||
T? peek();
|
||||
int get length;
|
||||
int get index;
|
||||
bool get isDone;
|
||||
void setPosition(int originalIndex);
|
||||
}
|
||||
|
||||
abstract class ITokenizer {
|
||||
List<TokenValue> tokenize();
|
||||
}
|
||||
|
||||
abstract class ILexer {
|
||||
List<LexerValue> lex();
|
||||
}
|
||||
3
lib/src/color_parser/color_parser.dart
Normal file
3
lib/src/color_parser/color_parser.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
export 'base.dart';
|
||||
export 'reader.dart';
|
||||
export 'parser.dart';
|
||||
265
lib/src/color_parser/parser.dart
Normal file
265
lib/src/color_parser/parser.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'base.dart';
|
||||
import 'reader.dart';
|
||||
import '../consts.dart' as consts;
|
||||
|
||||
/// Represents a string value with color information.
|
||||
///
|
||||
/// Can be used to store the text and color information for a single token.
|
||||
///
|
||||
/// Use [formatted] to get the ANSI formatted text, usable in a terminal.
|
||||
class ColorToken {
|
||||
/// The raw, uncoded text.
|
||||
String text;
|
||||
|
||||
/// The foreground color code.
|
||||
int fgColor;
|
||||
|
||||
/// The background color code.
|
||||
int bgColor;
|
||||
|
||||
/// Whether the text is bold.
|
||||
bool bold;
|
||||
|
||||
/// Whether the text is italic.
|
||||
bool italic;
|
||||
|
||||
/// Whether the text is underlined.
|
||||
bool underline;
|
||||
|
||||
/// Whether the text is an xterm256 color code. Otherwise, it is a standard color code.
|
||||
bool xterm256;
|
||||
|
||||
ColorToken({
|
||||
required this.text,
|
||||
required this.fgColor,
|
||||
required this.bgColor,
|
||||
this.bold = false,
|
||||
this.italic = false,
|
||||
this.underline = false,
|
||||
this.xterm256 = false,
|
||||
});
|
||||
|
||||
/// Create an empty token.
|
||||
factory ColorToken.empty() => ColorToken(text: '', fgColor: 0, bgColor: 0);
|
||||
|
||||
/// Create a token with default color and the given text.
|
||||
factory ColorToken.defaultColor(String text) =>
|
||||
ColorToken(text: text, fgColor: 0, bgColor: 0);
|
||||
|
||||
/// Returns true if the text is empty.
|
||||
bool get isEmpty => text.isEmpty;
|
||||
|
||||
/// Returns true if the text is not empty.
|
||||
bool get isNotEmpty => !isEmpty;
|
||||
|
||||
/// Get the formatted text as ANSI formatted text.
|
||||
///
|
||||
/// Outputting this value to a terminal will display the text with the correct colors.
|
||||
///
|
||||
/// To format the text in other ways, use the properties to get the [fgColor] and [bgColor],
|
||||
/// and construct it to whatever format you need.
|
||||
String get formatted => bgColor == 0
|
||||
? '\x1B[${fgColor}m$text\x1B[0m'
|
||||
: '\x1B[$fgColor;${bgColor}m$text\x1B[0m';
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final b = bold ? 'b' : '';
|
||||
final i = italic ? 'i' : '';
|
||||
final u = underline ? 'u' : '';
|
||||
final x = xterm256 ? 'x' : '';
|
||||
final flags = '$b$i$u$x';
|
||||
return 'ColoredText("$text", $fgColor:$bgColor, $flags)';
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => text.hashCode ^ fgColor.hashCode ^ bgColor.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ColorToken &&
|
||||
runtimeType == other.runtimeType &&
|
||||
text == other.text &&
|
||||
fgColor == other.fgColor &&
|
||||
bgColor == other.bgColor;
|
||||
|
||||
/// Set the style based on the given code.
|
||||
void setStyle(int code) {
|
||||
// debugPrint('setStyle: $code');
|
||||
if (code == consts.boldByte) {
|
||||
bold = true;
|
||||
} else if (code == consts.italicByte) {
|
||||
italic = true;
|
||||
} else if (code == consts.underlineByte) {
|
||||
underline = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parser to parse a string with color codes.
|
||||
class ColorParser implements IReader {
|
||||
final IReader reader;
|
||||
final _tokens = <TokenValue>[];
|
||||
|
||||
ColorParser._(this.reader);
|
||||
|
||||
factory ColorParser(String text) => ColorParser._(StringReader(text));
|
||||
|
||||
/// Parse the text and return a list of [ColorToken]s.
|
||||
///
|
||||
/// Each token represents a piece of text with color information. You can join all the text
|
||||
/// together (without separators) to get the original text, uncolored.
|
||||
///
|
||||
/// To get the colored text, use the [formatted] property of each token.
|
||||
List<ColorToken> parse() {
|
||||
final lexed = <ColorToken>[];
|
||||
while (!reader.isDone) {
|
||||
final token = reader.read();
|
||||
var cur = _getToken(token);
|
||||
lexed.add(cur);
|
||||
}
|
||||
return lexed;
|
||||
}
|
||||
|
||||
ColorToken _getToken(String char) {
|
||||
var token = ColorToken.empty();
|
||||
switch (char) {
|
||||
case consts.esc:
|
||||
String? next;
|
||||
// keep reading until we hit the end of the escape sequence or the end of the string
|
||||
while (!reader.isDone) {
|
||||
next = reader.peek();
|
||||
if (next == consts.esc) {
|
||||
break;
|
||||
}
|
||||
reader.read();
|
||||
if (next == '[') {
|
||||
final color = _consumeUntil('m');
|
||||
reader.read();
|
||||
final colors = color.split(';');
|
||||
final first = int.tryParse(colors[0]) ?? 0;
|
||||
final second = colors.length > 1 ? int.tryParse(colors[1]) ?? 0 : 0;
|
||||
final third = colors.length > 2 ? int.tryParse(colors[2]) ?? 0 : 0;
|
||||
int fg;
|
||||
int bg;
|
||||
if (first < 30) {
|
||||
token.setStyle(first);
|
||||
fg = second;
|
||||
bg = third;
|
||||
} else {
|
||||
if (first == 38 && second == 5) {
|
||||
token.xterm256 = true;
|
||||
fg = third;
|
||||
bg = 0;
|
||||
} else {
|
||||
fg = first;
|
||||
bg = second;
|
||||
}
|
||||
}
|
||||
token.fgColor = fg;
|
||||
token.bgColor = bg;
|
||||
// if (colors.length == 1) {
|
||||
// final code = int.tryParse(colors[0]) ?? 0;
|
||||
// if (code == consts.boldByte) {
|
||||
// token.bold = true;
|
||||
// } else if (code == consts.italicByte) {
|
||||
// token.italic = true;
|
||||
// } else if (code == consts.underlineByte) {
|
||||
// token.underline = true;
|
||||
// } else {
|
||||
// token.fgColor = int.tryParse(colors[0]) ?? 0;
|
||||
// }
|
||||
// } else if (colors.length == 2) {
|
||||
// final code = int.tryParse(colors[0]) ?? 0;
|
||||
// if (code < 30) {
|
||||
// token.fgColor = int.tryParse(colors[1]) ?? 0;
|
||||
// } else {
|
||||
// token.fgColor = int.tryParse(colors[1]) ?? 0;
|
||||
// token.bgColor = int.tryParse(colors[0]) ?? 1;
|
||||
// }
|
||||
// } else if (colors.length == 3) {
|
||||
// if (colors[0] == '38' && colors[1] == '5') {
|
||||
// token.xterm256 = true;
|
||||
// token.fgColor = int.tryParse(colors[2]) ?? 0;
|
||||
// } else {
|
||||
// token.bgColor = int.tryParse(colors[0]) ?? 1;
|
||||
// token.fgColor = int.tryParse(colors[1]) ?? 0;
|
||||
// }
|
||||
// }
|
||||
token.text = _consumeUntil(consts.esc);
|
||||
return token;
|
||||
}
|
||||
if (next == null) {
|
||||
break;
|
||||
}
|
||||
token.text += next;
|
||||
}
|
||||
return token;
|
||||
default:
|
||||
token.text += char;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
String _consumeUntil(String char) {
|
||||
String? next = reader.peek();
|
||||
if (next == null) {
|
||||
return '';
|
||||
}
|
||||
var result = '';
|
||||
while (!reader.isDone) {
|
||||
if (next == char) {
|
||||
break;
|
||||
}
|
||||
next = reader.peek();
|
||||
if (next == null) {
|
||||
break;
|
||||
}
|
||||
result += reader.read();
|
||||
next = reader.peek();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// String peekUntil(String char) {
|
||||
// String? next = reader.peek();
|
||||
// if (next == null) {
|
||||
// return '';
|
||||
// }
|
||||
// var result = '';
|
||||
// var originalIndex = reader.index;
|
||||
// while (!reader.isDone) {
|
||||
// if (next == char) {
|
||||
// break;
|
||||
// }
|
||||
// next = reader.peek();
|
||||
// if (next == null) {
|
||||
// break;
|
||||
// }
|
||||
// result += reader.read();
|
||||
// next = reader.peek();
|
||||
// }
|
||||
// reader.setPosition(originalIndex);
|
||||
// return result;
|
||||
// }
|
||||
|
||||
@override
|
||||
int index = 0;
|
||||
|
||||
@override
|
||||
bool get isDone => index >= reader.length;
|
||||
|
||||
@override
|
||||
peek() => _tokens[index];
|
||||
|
||||
@override
|
||||
read() => _tokens[index++];
|
||||
|
||||
@override
|
||||
int get length => _tokens.length;
|
||||
|
||||
@override
|
||||
setPosition(int position) => index = position;
|
||||
}
|
||||
|
||||
37
lib/src/color_parser/reader.dart
Normal file
37
lib/src/color_parser/reader.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'base.dart';
|
||||
|
||||
class StringReader implements IReader<String> {
|
||||
final String input;
|
||||
|
||||
@override
|
||||
int index = 0;
|
||||
|
||||
StringReader(this.input);
|
||||
|
||||
@override
|
||||
int get length => input.length;
|
||||
|
||||
@override
|
||||
bool get isDone => index >= length;
|
||||
|
||||
@override
|
||||
String? peek() {
|
||||
if (isDone) {
|
||||
return null;
|
||||
}
|
||||
return input[index];
|
||||
}
|
||||
|
||||
@override
|
||||
String? read() {
|
||||
if (isDone) {
|
||||
return null;
|
||||
}
|
||||
return input[index++];
|
||||
}
|
||||
|
||||
@override
|
||||
void setPosition(int position) {
|
||||
index = position;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
const newline = '\n';
|
||||
const cr = '\r';
|
||||
const lf = '\n';
|
||||
const esc = '\x1B';
|
||||
const colorPatternRaw = r'\[\d*m';
|
||||
|
||||
const boldByte = 1;
|
||||
const italicByte = 3;
|
||||
const underlineByte = 4;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'color_parser/parser.dart';
|
||||
import 'consts.dart';
|
||||
import 'parser.dart';
|
||||
import 'symbols.dart';
|
||||
@@ -6,11 +7,13 @@ class Message {
|
||||
final List<int> bytes;
|
||||
late final String text;
|
||||
late final List<List<int>> commands;
|
||||
late final List<ColorToken> coloredText;
|
||||
|
||||
Message(this.bytes) {
|
||||
final parser = MessageParser(bytes).parse();
|
||||
commands = parser.commands;
|
||||
text = parser.text;
|
||||
final stringParser = MessageParser(bytes).parse();
|
||||
commands = stringParser.commands;
|
||||
text = stringParser.text;
|
||||
coloredText = ColorParser(text).parse();
|
||||
}
|
||||
|
||||
/// Returns true if the message contains the given WILL command
|
||||
@@ -74,7 +77,7 @@ class Message {
|
||||
}
|
||||
|
||||
String toDebugString() {
|
||||
return '${commands.map((e) => e.map((x) => symbolMap[x] ?? x)).join(' ')}${lf}n$text';
|
||||
return '${commands.map((e) => e.map((x) => symbolMap[x] ?? x)).join(' ')}$lf$text';
|
||||
}
|
||||
|
||||
int get firstLiteralByteIndex {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
scripts:
|
||||
- name: mud-test
|
||||
cmd: HOST=aardmud.net PORT=6555 dart example/ctelnet_example.dart
|
||||
env:
|
||||
HOST: aardmud.net
|
||||
PORT: 6555
|
||||
cmd: HOST=smud.ourmmo.com PORT=3000 dart example/example.dart
|
||||
|
||||
29
test/color_parser_test.dart
Normal file
29
test/color_parser_test.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:ctelnet/src/color_parser/parser.dart';
|
||||
import 'package:ctelnet/src/consts.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const inputs = [
|
||||
'$esc[32mYou are standing in a small clearing.$esc[0m',
|
||||
'You are standing in a small clearing.',
|
||||
'$esc[0m$esc[1m$esc[0m$esc[1m$esc[31mWelcome to SimpleMUD$esc[0m$esc[1m$esc[0m',
|
||||
'$esc[0m$esc[37m$esc[0m$esc[37m$esc[1m[$esc[0m$esc[37m$esc[1m$esc[32m10$esc[0m$esc[37m$esc[1m/10]$esc[0m$esc[37m$esc[0m'
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('ColorParser', () {
|
||||
test('parse colors - simple', () {
|
||||
final input = inputs[0];
|
||||
final output = ColorParser(input).parse();
|
||||
expect(output, [
|
||||
ColorToken(text: 'You are standing in a small clearing.', fgColor: 32, bgColor: 0),
|
||||
ColorToken.empty(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('formatted', () {
|
||||
final input = inputs[0];
|
||||
final output = ColorParser(input).parse();
|
||||
expect(output[0].formatted, inputs[0]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,26 +7,32 @@ import 'package:test/test.dart';
|
||||
final host = InternetAddress.anyIPv4;
|
||||
final port = 5555;
|
||||
ServerSocket? server;
|
||||
Future<StreamSubscription<Socket>> startServer() async {
|
||||
Future<StreamSubscription<Socket>> startServer({bool verbose = false}) async {
|
||||
void d(Object msg) {
|
||||
if (verbose) {
|
||||
print(msg);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ServerSocket> serverFuture = ServerSocket.bind(host, port);
|
||||
final timeout = Duration(seconds: 1);
|
||||
await Future.delayed(timeout);
|
||||
print('Server init on $host:$port');
|
||||
d('Server init on $host:$port');
|
||||
server = await serverFuture;
|
||||
print('Server started on ${server!.address.address}:${server!.port}');
|
||||
d('Server started on ${server!.address.address}:${server!.port}');
|
||||
return server!.listen(
|
||||
(Socket socket) {
|
||||
print('Listening on ${socket.remoteAddress.address}:${socket.port}');
|
||||
d('Listening on ${socket.remoteAddress.address}:${socket.port}');
|
||||
socket.listen((List<int> data) {
|
||||
String result = String.fromCharCodes(data);
|
||||
socket.write(result);
|
||||
});
|
||||
},
|
||||
onError: (e) {
|
||||
print('Server error: $e');
|
||||
d('Server error: $e');
|
||||
},
|
||||
onDone: () {
|
||||
print('Server done');
|
||||
d('Server done');
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
@@ -42,7 +48,6 @@ void main() {
|
||||
onError: (e) {
|
||||
expect(e, isA<TimeoutException>());
|
||||
},
|
||||
onData: (d) {},
|
||||
onConnect: () {},
|
||||
onDisconnect: () {},
|
||||
);
|
||||
@@ -60,7 +65,6 @@ void main() {
|
||||
port: port,
|
||||
timeout: Duration(seconds: 1),
|
||||
onError: (e) {},
|
||||
onData: (d) {},
|
||||
onConnect: () async {
|
||||
expect(client!.status, equals(ConnectionStatus.connected));
|
||||
},
|
||||
@@ -75,3 +79,4 @@ void main() {
|
||||
expect(client.status, equals(ConnectionStatus.disconnected));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user