Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../im/chat_prefs.dart';
|
||||
import '../pages/chat_page.dart';
|
||||
import '../pages/notices_page.dart';
|
||||
import '../pages/pick_people_page.dart';
|
||||
import '../pages/scan_login_page.dart';
|
||||
import '../pages/system_notice_page.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/desktop_ui.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
|
||||
class MessagesPage extends StatefulWidget {
|
||||
const MessagesPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
this.desktopPane = false,
|
||||
this.desktopStyle = false,
|
||||
this.selectedId,
|
||||
this.onSelectConv,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final bool desktopPane;
|
||||
final bool desktopStyle;
|
||||
final String? selectedId;
|
||||
final void Function(Map<String, dynamic> row)? onSelectConv;
|
||||
|
||||
@override
|
||||
State<MessagesPage> createState() => _MessagesPageState();
|
||||
}
|
||||
|
||||
class _MessagesPageState extends State<MessagesPage> {
|
||||
List<Map<String, dynamic>> _items = [];
|
||||
bool _loading = true;
|
||||
String _err = '';
|
||||
String _filter = 'all';
|
||||
String _q = '';
|
||||
bool _showSearch = false;
|
||||
Timer? _imDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.session.im.addListener(_onIm);
|
||||
ChatPrefs.ensure().then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_imDebounce?.cancel();
|
||||
widget.session.im.removeListener(_onIm);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onIm() {
|
||||
_imDebounce?.cancel();
|
||||
_imDebounce = Timer(const Duration(milliseconds: 250), () {
|
||||
if (mounted) _load();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await widget.api.get('/im/conversations');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = asMaps(data);
|
||||
_loading = false;
|
||||
_err = '';
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (_items.isEmpty) _err = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
int get _unread {
|
||||
var n = 0;
|
||||
for (final r in _items) {
|
||||
if (ChatPrefs.muted('${r['id'] ?? ''}')) continue;
|
||||
final id = '${r['id'] ?? ''}';
|
||||
final fake = ChatPrefs.fakeUnread(id);
|
||||
if (fake > 0) {
|
||||
n += fake;
|
||||
continue;
|
||||
}
|
||||
n += (r['unread'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
Future<void> _showDesktopConvMenu(Map<String, dynamic> r, Offset pos) async {
|
||||
final id = '${r['id'] ?? ''}';
|
||||
if (id.isEmpty) return;
|
||||
final muted = ChatPrefs.muted(id);
|
||||
final pinned = ChatPrefs.pinned(id);
|
||||
final unread = (r['unread'] as num?)?.toInt() ?? 0;
|
||||
final fake = ChatPrefs.fakeUnread(id);
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1),
|
||||
items: [
|
||||
if (unread == 0 && fake == 0)
|
||||
const PopupMenuItem(value: 'unread', child: Text('标为未读')),
|
||||
PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')),
|
||||
PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')),
|
||||
const PopupMenuItem(value: 'hide', child: Text('不显示')),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
);
|
||||
if (!mounted || selected == null) return;
|
||||
switch (selected) {
|
||||
case 'unread':
|
||||
await ChatPrefs.setFakeUnread(id, 1);
|
||||
case 'mute':
|
||||
await ChatPrefs.setMuted(id, !muted);
|
||||
case 'pin':
|
||||
await ChatPrefs.setPinned(id, !pinned);
|
||||
case 'hide':
|
||||
await ChatPrefs.setHidden(id, true);
|
||||
case 'clear':
|
||||
await ChatPrefs.clearHistory(id);
|
||||
case 'delete':
|
||||
await ChatPrefs.setHidden(id, true);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _wrapConv(Map<String, dynamic> r, Widget child) {
|
||||
if (!widget.desktopStyle) return child;
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (d) => _showDesktopConvMenu(r, d.globalPosition),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _newChat({required bool group}) async {
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PickPeoplePage(
|
||||
api: widget.api,
|
||||
title: group ? '选择联系人' : '发起单聊',
|
||||
multiple: group,
|
||||
exclude: {widget.session.userId},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
if (!group) {
|
||||
final p = picked.first;
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${p['id']}',
|
||||
peerName: '${p['name'] ?? '同事'}',
|
||||
peerAvatarFileId: '${p['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
_load();
|
||||
return;
|
||||
}
|
||||
final name = TextEditingController();
|
||||
if (!mounted) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('群名称'),
|
||||
content: TextField(
|
||||
controller: name,
|
||||
decoration: const InputDecoration(hintText: '例如:三维项目组')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('创建')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(),
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _shown {
|
||||
var list = _items;
|
||||
// 只有用户明确执行“不显示/删除”后才隐藏;阅读状态不会改变会话是否存在。
|
||||
list = list.where((e) {
|
||||
final id = '${e['id'] ?? ''}';
|
||||
return !ChatPrefs.hidden(id);
|
||||
}).toList();
|
||||
if (_filter == 'unread')
|
||||
list =
|
||||
list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList();
|
||||
if (_filter == 'dm')
|
||||
list = list.where((e) => e['type'] != 'group').toList();
|
||||
if (_filter == 'group')
|
||||
list = list.where((e) => e['type'] == 'group').toList();
|
||||
if (_q.isNotEmpty) {
|
||||
list = list
|
||||
.where((e) =>
|
||||
'${e['name']}${e['peerName']}${e['lastText']}'.contains(_q))
|
||||
.toList();
|
||||
}
|
||||
list = [...list]..sort((a, b) {
|
||||
final ap = ChatPrefs.pinned('${a['id'] ?? ''}');
|
||||
final bp = ChatPrefs.pinned('${b['id'] ?? ''}');
|
||||
if (ap == bp) return 0;
|
||||
return ap ? -1 : 1;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = widget.desktopStyle
|
||||
? DesktopPaneHeader(
|
||||
title: _unread > 0 ? '消息($_unread)' : '消息',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||||
icon: const Icon(Icons.search, color: kInk, size: 20),
|
||||
),
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
onPressed: () => showPlusMenu(ctx, [
|
||||
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
|
||||
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
|
||||
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
|
||||
]),
|
||||
icon: const Icon(Icons.add, color: kInk, size: 22),
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_showSearch) ...[
|
||||
DesktopSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
_filterChip('all', '全部'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('unread', '未读'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('dm', '单聊'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('group', '群聊'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
WxHeader(
|
||||
title: _unread > 0 ? '消息($_unread)' : '消息',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _showSearch = !_showSearch),
|
||||
icon: const Icon(Icons.search, color: kInk),
|
||||
),
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
onPressed: () => showPlusMenu(ctx, [
|
||||
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
|
||||
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
|
||||
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
|
||||
]),
|
||||
icon: const Icon(Icons.add_circle_outline, color: kInk),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_showSearch) WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_filterChip('all', '全部'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('unread', '未读'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('dm', '单聊'),
|
||||
const SizedBox(width: 8),
|
||||
_filterChip('group', '群聊'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return ColoredBox(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
header,
|
||||
if (_loading)
|
||||
const LinearProgressIndicator(minHeight: 2, color: kBind),
|
||||
if (_err.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_err, style: const TextStyle(color: kDanger))),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ConvTile(
|
||||
title: '公告',
|
||||
preview: '公司通知与制度',
|
||||
avatarIcon: Icons.campaign,
|
||||
avatarColor: const Color(0xFFE75D5D),
|
||||
avatarLabel: '告',
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => NoticesPage(api: widget.api),
|
||||
)),
|
||||
),
|
||||
if (_shown.isEmpty && !_loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyHint('还没有会话。点右上角 + 或到通讯录找同事。'),
|
||||
),
|
||||
for (final r in _shown)
|
||||
_wrapConv(
|
||||
r,
|
||||
widget.desktopStyle
|
||||
? ConvTile(
|
||||
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
preview: '${r['lastText'] ?? ''}',
|
||||
time: shortTime(r['lastAt']),
|
||||
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
|
||||
unread: ChatPrefs.muted('${r['id'] ?? ''}')
|
||||
? 0
|
||||
: (ChatPrefs.fakeUnread('${r['id'] ?? ''}') > 0
|
||||
? ChatPrefs.fakeUnread('${r['id'] ?? ''}')
|
||||
: ((r['unread'] as num?)?.toInt() ?? 0)),
|
||||
avatarLabel: '${r['peerId']}' == '0' ? '?' : '${r['name'] ?? r['peerName'] ?? '同'}',
|
||||
avatarIcon: '${r['peerId']}' == '0' ? Icons.notifications : (r['type'] == 'group' ? Icons.groups : null),
|
||||
avatarColor: '${r['peerId']}' == '0' ? const Color(0xFF07C160) : (r['type'] == 'group' ? const Color(0xFF07C160) : null),
|
||||
avatarFileId: r['type'] == 'group' ? null : '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
|
||||
onTap: () async {
|
||||
final id = '${r['id'] ?? ''}';
|
||||
if (id.isNotEmpty) await ChatPrefs.setFakeUnread(id, 0);
|
||||
if (widget.desktopPane && widget.onSelectConv != null) {
|
||||
widget.onSelectConv!(r);
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => '${r['peerId']}' == '0'
|
||||
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: id)
|
||||
: ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${r['peerId'] ?? ''}',
|
||||
conversationId: id,
|
||||
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
isGroup: r['type'] == 'group',
|
||||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
_load();
|
||||
},
|
||||
)
|
||||
: _SwipeConversation(
|
||||
id: '${r['id'] ?? ''}',
|
||||
api: widget.api,
|
||||
onChanged: _load,
|
||||
child: ConvTile(
|
||||
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
preview: '${r['lastText'] ?? ''}',
|
||||
time: shortTime(r['lastAt']),
|
||||
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
|
||||
unread: ChatPrefs.muted('${r['id'] ?? ''}')
|
||||
? 0
|
||||
: ((r['unread'] as num?)?.toInt() ?? 0),
|
||||
avatarLabel: '${r['peerId']}' == '0'
|
||||
? '?'
|
||||
: '${r['name'] ?? r['peerName'] ?? '同'}',
|
||||
avatarIcon: '${r['peerId']}' == '0'
|
||||
? Icons.notifications
|
||||
: (r['type'] == 'group' ? Icons.groups : null),
|
||||
avatarColor: '${r['peerId']}' == '0'
|
||||
? const Color(0xFF07C160)
|
||||
: (r['type'] == 'group'
|
||||
? const Color(0xFF07C160)
|
||||
: null),
|
||||
avatarFileId: r['type'] == 'group'
|
||||
? null
|
||||
: '${r['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
|
||||
onTap: () async {
|
||||
if (widget.desktopPane && widget.onSelectConv != null) {
|
||||
widget.onSelectConv!(r);
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => '${r['peerId']}' == '0'
|
||||
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: '${r['id'] ?? ''}')
|
||||
: ChatPage(
|
||||
session: widget.session,
|
||||
api: widget.api,
|
||||
peerId: '${r['peerId'] ?? ''}',
|
||||
conversationId: '${r['id'] ?? ''}',
|
||||
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
|
||||
isGroup: r['type'] == 'group',
|
||||
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
||||
),
|
||||
));
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filterChip(String id, String label) {
|
||||
final on = _filter == id;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _filter = id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? const Color(0xFFE8F3FF) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: on ? kBind : kMute,
|
||||
fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 微信式左滑操作栏:操作不会误删服务器会话,删除仅隐藏本机列表。
|
||||
class _SwipeConversation extends StatefulWidget {
|
||||
const _SwipeConversation(
|
||||
{required this.id,
|
||||
required this.api,
|
||||
required this.child,
|
||||
required this.onChanged});
|
||||
final String id;
|
||||
final OaClient api;
|
||||
final Widget child;
|
||||
final Future<void> Function() onChanged;
|
||||
|
||||
@override
|
||||
State<_SwipeConversation> createState() => _SwipeConversationState();
|
||||
}
|
||||
|
||||
class _SwipeConversationState extends State<_SwipeConversation> {
|
||||
double _offset = 0;
|
||||
static const _width = 216.0;
|
||||
|
||||
Future<void> _hide() async {
|
||||
if (!mounted) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('隐藏会话'),
|
||||
content: const Text('仅从消息列表隐藏,不会删除聊天记录。确定继续吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) {
|
||||
if (mounted) setState(() => _offset = 0);
|
||||
return;
|
||||
}
|
||||
await ChatPrefs.setHidden(widget.id, true);
|
||||
await widget.onChanged();
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
await ChatPrefs.setMuted(widget.id, !ChatPrefs.muted(widget.id));
|
||||
if (mounted) setState(() => _offset = 0);
|
||||
await widget.onChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 76,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragUpdate: (d) => setState(() => _offset = (_offset + d.delta.dx).clamp(-_width, 0)),
|
||||
onHorizontalDragEnd: (_) => setState(() => _offset = _offset < -80 ? -_width : 0),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_action('免打扰', const Color(0xFFFFA940), _toggleMute),
|
||||
_action('不显示', const Color(0xFF8C8C8C), _hide),
|
||||
_action('删除', const Color(0xFFF5222D), _hide),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
transform: Matrix4.translationValues(_offset, 0, 0),
|
||||
color: Colors.white,
|
||||
child: widget.child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _action(String label, Color color, Future<void> Function() onTap) {
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
height: double.infinity,
|
||||
child: Material(
|
||||
color: color,
|
||||
child: InkWell(
|
||||
onTap: () async => onTap(),
|
||||
child: Center(
|
||||
child: Text(label,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user