✨ Leave & delete channel
This commit is contained in:
parent
a02831644c
commit
a761b80499
@ -22,6 +22,7 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"exit": "Exit",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"report": "Report",
|
"report": "Report",
|
||||||
@ -72,6 +73,8 @@
|
|||||||
"chatChannelAliasLabel": "Channel Alias",
|
"chatChannelAliasLabel": "Channel Alias",
|
||||||
"chatChannelNameLabel": "Channel Name",
|
"chatChannelNameLabel": "Channel Name",
|
||||||
"chatChannelDescriptionLabel": "Channel Description",
|
"chatChannelDescriptionLabel": "Channel Description",
|
||||||
|
"chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.",
|
||||||
|
"chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!",
|
||||||
"chatMessagePlaceholder": "Write a message...",
|
"chatMessagePlaceholder": "Write a message...",
|
||||||
"chatMessageEditNotify": "You are about editing a message.",
|
"chatMessageEditNotify": "You are about editing a message.",
|
||||||
"chatMessageReplyNotify": "You are about replying a message.",
|
"chatMessageReplyNotify": "You are about replying a message.",
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
"action": "操作",
|
"action": "操作",
|
||||||
"apply": "应用",
|
"apply": "应用",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
"exit": "离开",
|
||||||
"report": "举报",
|
"report": "举报",
|
||||||
"reply": "回复",
|
"reply": "回复",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
@ -72,6 +73,8 @@
|
|||||||
"chatChannelAliasLabel": "频道别名",
|
"chatChannelAliasLabel": "频道别名",
|
||||||
"chatChannelNameLabel": "频道名称",
|
"chatChannelNameLabel": "频道名称",
|
||||||
"chatChannelDescriptionLabel": "频道简介",
|
"chatChannelDescriptionLabel": "频道简介",
|
||||||
|
"chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。",
|
||||||
|
"chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!",
|
||||||
"chatMessagePlaceholder": "发条消息……",
|
"chatMessagePlaceholder": "发条消息……",
|
||||||
"chatMessageEditNotify": "你正在编辑信息中……",
|
"chatMessageEditNotify": "你正在编辑信息中……",
|
||||||
"chatMessageReplyNotify": "你正在回复消息中……",
|
"chatMessageReplyNotify": "你正在回复消息中……",
|
||||||
|
@ -32,17 +32,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
final http.Client _client = http.Client();
|
final http.Client _client = http.Client();
|
||||||
|
|
||||||
Future<void> fetchMetadata() async {
|
Future<Channel> fetchMetadata() async {
|
||||||
var uri = getRequestUri('messaging', '/api/channels/${widget.alias}');
|
var uri = getRequestUri('messaging', '/api/channels/${widget.alias}');
|
||||||
var res = await _client.get(uri);
|
var res = await _client.get(uri);
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
final result = jsonDecode(utf8.decode(res.bodyBytes));
|
final result = jsonDecode(utf8.decode(res.bodyBytes));
|
||||||
setState(() => _channelMeta = Channel.fromJson(result));
|
setState(() => _channelMeta = Channel.fromJson(result));
|
||||||
|
return _channelMeta!;
|
||||||
} else {
|
} else {
|
||||||
var message = utf8.decode(res.bodyBytes);
|
var message = utf8.decode(res.bodyBytes);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text("Something went wrong... $message")),
|
SnackBar(content: Text("Something went wrong... $message")),
|
||||||
);
|
);
|
||||||
|
throw Exception(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,57 +139,67 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
appBarActions: [
|
appBarActions: [
|
||||||
_channelMeta != null ? ChannelAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()) : Container(),
|
_channelMeta != null ? ChannelAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()) : Container(),
|
||||||
],
|
],
|
||||||
child: ChatMaintainer(
|
child: FutureBuilder(
|
||||||
child: Column(
|
future: fetchMetadata(),
|
||||||
children: [
|
builder: (context, snapshot) {
|
||||||
Expanded(
|
if (!snapshot.hasData || snapshot.data == null) {
|
||||||
child: PagedListView<int, Message>(
|
return const Center(child: CircularProgressIndicator());
|
||||||
reverse: true,
|
}
|
||||||
pagingController: _pagingController,
|
|
||||||
builderDelegate: PagedChildBuilderDelegate<Message>(
|
return ChatMaintainer(
|
||||||
noItemsFoundIndicatorBuilder: (_) => Container(),
|
channel: snapshot.data!,
|
||||||
itemBuilder: (context, item, index) {
|
child: Column(
|
||||||
bool isMerged = false, hasMerged = false;
|
children: [
|
||||||
if (index > 0) {
|
Expanded(
|
||||||
hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item);
|
child: PagedListView<int, Message>(
|
||||||
}
|
reverse: true,
|
||||||
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
|
pagingController: _pagingController,
|
||||||
isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]);
|
builderDelegate: PagedChildBuilderDelegate<Message>(
|
||||||
}
|
noItemsFoundIndicatorBuilder: (_) => Container(),
|
||||||
return InkWell(
|
itemBuilder: (context, item, index) {
|
||||||
child: Container(
|
bool isMerged = false, hasMerged = false;
|
||||||
padding: EdgeInsets.only(
|
if (index > 0) {
|
||||||
top: !isMerged ? 8 : 0,
|
hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item);
|
||||||
bottom: !hasMerged ? 8 : 0,
|
}
|
||||||
left: 12,
|
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
|
||||||
right: 12,
|
isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]);
|
||||||
),
|
}
|
||||||
child: ChatMessage(
|
return InkWell(
|
||||||
key: Key('m${item.id}'),
|
child: Container(
|
||||||
item: item,
|
padding: EdgeInsets.only(
|
||||||
underMerged: isMerged,
|
top: !isMerged ? 8 : 0,
|
||||||
),
|
bottom: !hasMerged ? 8 : 0,
|
||||||
),
|
left: 12,
|
||||||
onLongPress: () => viewActions(item),
|
right: 12,
|
||||||
);
|
),
|
||||||
},
|
child: ChatMessage(
|
||||||
|
key: Key('m${item.id}'),
|
||||||
|
item: item,
|
||||||
|
underMerged: isMerged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onLongPress: () => viewActions(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
ChatMessageEditor(
|
||||||
|
channel: widget.alias,
|
||||||
|
editing: _editingItem,
|
||||||
|
replying: _replyingItem,
|
||||||
|
onReset: () => setState(() {
|
||||||
|
_editingItem = null;
|
||||||
|
_replyingItem = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
ChatMessageEditor(
|
onInsertMessage: (message) => addMessage(message),
|
||||||
channel: widget.alias,
|
onUpdateMessage: (message) => updateMessage(message),
|
||||||
editing: _editingItem,
|
onDeleteMessage: (message) => deleteMessage(message),
|
||||||
replying: _replyingItem,
|
);
|
||||||
onReset: () => setState(() {
|
},
|
||||||
_editingItem = null;
|
|
||||||
_replyingItem = null;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onInsertMessage: (message) => addMessage(message),
|
|
||||||
onUpdateMessage: (message) => updateMessage(message),
|
|
||||||
onDeleteMessage: (message) => deleteMessage(message),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -99,13 +99,17 @@ class _ChatIndexScreenState extends State<ChatIndexScreen> {
|
|||||||
),
|
),
|
||||||
title: Text(element.name),
|
title: Text(element.name),
|
||||||
subtitle: Text(element.description),
|
subtitle: Text(element.description),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
router.pushNamed(
|
final result = await router.pushNamed(
|
||||||
'chat.channel',
|
'chat.channel',
|
||||||
pathParameters: {
|
pathParameters: {
|
||||||
'channel': element.alias,
|
'channel': element.alias,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
switch(result) {
|
||||||
|
case 'refresh':
|
||||||
|
fetchChannels();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:solian/models/channel.dart';
|
import 'package:solian/models/channel.dart';
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/providers/auth.dart';
|
||||||
import 'package:solian/router.dart';
|
import 'package:solian/router.dart';
|
||||||
|
import 'package:solian/widgets/chat/channel_deletion.dart';
|
||||||
import 'package:solian/widgets/indent_wrapper.dart';
|
import 'package:solian/widgets/indent_wrapper.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
@ -16,7 +18,20 @@ class ChatManageScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChatManageScreenState extends State<ChatManageScreen> {
|
class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||||
bool isOwned = false;
|
bool _isOwned = false;
|
||||||
|
|
||||||
|
void promptLeaveChannel() async {
|
||||||
|
final did = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ChannelDeletion(
|
||||||
|
channel: widget.channel,
|
||||||
|
isOwned: _isOwned,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (did == true && router.canPop()) {
|
||||||
|
router.pop('disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -27,7 +42,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
final prof = await auth.getProfiles();
|
final prof = await auth.getProfiles();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
isOwned = prof['id'] == widget.channel.account.externalId;
|
_isOwned = prof['id'] == widget.channel.account.externalId;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -41,7 +56,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) {
|
router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) {
|
||||||
if (did == true) {
|
if (did == true) {
|
||||||
if (router.canPop()) router.pop(true);
|
if (router.canPop()) router.pop('refresh');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -64,10 +79,12 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
child: Icon(Icons.tag, color: Colors.white),
|
child: Icon(Icons.tag, color: Colors.white),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Expanded(
|
||||||
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge),
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall),
|
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
])
|
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
]),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -91,7 +108,13 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
...(isOwned ? authorizedItems : List.empty()),
|
...(_isOwned ? authorizedItems : List.empty()),
|
||||||
|
const Divider(thickness: 0.3),
|
||||||
|
ListTile(
|
||||||
|
leading: _isOwned ? const Icon(Icons.delete) : const Icon(Icons.exit_to_app),
|
||||||
|
title: Text(_isOwned ? AppLocalizations.of(context)!.delete : AppLocalizations.of(context)!.exit),
|
||||||
|
onTap: () => promptLeaveChannel(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -13,12 +13,18 @@ class ChannelAction extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
router.pushNamed(
|
final result = await router.pushNamed(
|
||||||
'chat.channel.manage',
|
'chat.channel.manage',
|
||||||
extra: channel,
|
extra: channel,
|
||||||
pathParameters: {'channel': channel.alias},
|
pathParameters: {'channel': channel.alias},
|
||||||
);
|
);
|
||||||
|
switch(result) {
|
||||||
|
case 'disposed':
|
||||||
|
if(router.canPop()) router.pop('refresh');
|
||||||
|
case 'refresh':
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
style: TextButton.styleFrom(shape: const CircleBorder()),
|
style: TextButton.styleFrom(shape: const CircleBorder()),
|
||||||
|
100
lib/widgets/chat/channel_deletion.dart
Normal file
100
lib/widgets/chat/channel_deletion.dart
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:solian/models/channel.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:solian/providers/auth.dart';
|
||||||
|
import 'package:solian/utils/service_url.dart';
|
||||||
|
|
||||||
|
class ChannelDeletion extends StatefulWidget {
|
||||||
|
final Channel channel;
|
||||||
|
final bool isOwned;
|
||||||
|
|
||||||
|
const ChannelDeletion({super.key, required this.channel, required this.isOwned});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChannelDeletion> createState() => _ChannelDeletionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelDeletionState extends State<ChannelDeletion> {
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
Future<void> deleteChannel() async {
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
if (!await auth.isAuthorized()) {
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await auth.client!.delete(
|
||||||
|
getRequestUri('messaging', '/api/channels/${widget.channel.id}'),
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
var message = utf8.decode(res.bodyBytes);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Something went wrong... $message")),
|
||||||
|
);
|
||||||
|
} else if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> leaveChannel() async {
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
if (!await auth.isAuthorized()) {
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await auth.client!.post(
|
||||||
|
getRequestUri('messaging', '/api/channels/${widget.channel.alias}/leave'),
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
var message = utf8.decode(res.bodyBytes);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Something went wrong... $message")),
|
||||||
|
);
|
||||||
|
} else if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final content = widget.isOwned
|
||||||
|
? AppLocalizations.of(context)!.chatChannelDeleteConfirm
|
||||||
|
: AppLocalizations.of(context)!.chatChannelLeaveConfirm;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(AppLocalizations.of(context)!.confirmation),
|
||||||
|
content: Text(content),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.confirmCancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (widget.isOwned) {
|
||||||
|
deleteChannel();
|
||||||
|
} else {
|
||||||
|
leaveChannel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(context)!.confirmOkay),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:solian/models/channel.dart';
|
||||||
import 'package:solian/models/message.dart';
|
import 'package:solian/models/message.dart';
|
||||||
import 'package:solian/models/packet.dart';
|
import 'package:solian/models/packet.dart';
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/providers/auth.dart';
|
||||||
@ -10,6 +11,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|||||||
|
|
||||||
class ChatMaintainer extends StatefulWidget {
|
class ChatMaintainer extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final Channel channel;
|
||||||
final Function(Message val) onInsertMessage;
|
final Function(Message val) onInsertMessage;
|
||||||
final Function(Message val) onUpdateMessage;
|
final Function(Message val) onUpdateMessage;
|
||||||
final Function(Message val) onDeleteMessage;
|
final Function(Message val) onDeleteMessage;
|
||||||
@ -17,6 +19,7 @@ class ChatMaintainer extends StatefulWidget {
|
|||||||
const ChatMaintainer({
|
const ChatMaintainer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
|
required this.channel,
|
||||||
required this.onInsertMessage,
|
required this.onInsertMessage,
|
||||||
required this.onUpdateMessage,
|
required this.onUpdateMessage,
|
||||||
required this.onDeleteMessage,
|
required this.onDeleteMessage,
|
||||||
@ -46,13 +49,16 @@ class _ChatMaintainerState extends State<ChatMaintainer> {
|
|||||||
final result = NetworkPackage.fromJson(jsonDecode(event));
|
final result = NetworkPackage.fromJson(jsonDecode(event));
|
||||||
switch (result.method) {
|
switch (result.method) {
|
||||||
case 'messages.new':
|
case 'messages.new':
|
||||||
widget.onInsertMessage(Message.fromJson(result.payload!));
|
final payload = Message.fromJson(result.payload!);
|
||||||
|
if (payload.channelId == widget.channel.id) widget.onInsertMessage(payload);
|
||||||
break;
|
break;
|
||||||
case 'messages.update':
|
case 'messages.update':
|
||||||
widget.onUpdateMessage(Message.fromJson(result.payload!));
|
final payload = Message.fromJson(result.payload!);
|
||||||
|
if (payload.channelId == widget.channel.id) widget.onUpdateMessage(payload);
|
||||||
break;
|
break;
|
||||||
case 'messages.burnt':
|
case 'messages.burnt':
|
||||||
widget.onDeleteMessage(Message.fromJson(result.payload!));
|
final payload = Message.fromJson(result.payload!);
|
||||||
|
if (payload.channelId == widget.channel.id) widget.onDeleteMessage(payload);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user