mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 16:46:12 +01:00
feat: add direct message link (#12138)
* feat: add direct message link /message/:mid add /:index? to chat routes add copy link to chat messages add messageCount to each room object add infinitescroll in both directions to chat * fix more tests * test: more text fixes * test: fix tests * remove async * dont crash if element not in dom clamp scrollToIndex values to 0, msgCount
This commit is contained in:
committed by
GitHub
parent
7a8c27bf90
commit
4c4f3ac983
@@ -68,6 +68,7 @@
|
||||
"chat.in-room": "In this room",
|
||||
"chat.kick": "Kick",
|
||||
"chat.show-ip": "Show IP",
|
||||
"chat.copy-link": "Copy link",
|
||||
"chat.owner": "Room Owner",
|
||||
"chat.grant-rescind-ownership": "Grant/Rescind Ownership",
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ RoomObject:
|
||||
userCount:
|
||||
type: number
|
||||
description: number of users in this chat room
|
||||
messageCount:
|
||||
type: number
|
||||
description: number of messages sent in this chat room
|
||||
groups:
|
||||
type: array
|
||||
description: list of groups that can access the room
|
||||
|
||||
@@ -310,8 +310,14 @@ paths:
|
||||
$ref: 'read/notifications.yaml'
|
||||
"/api/user/{userslug}/chats/{roomid}":
|
||||
$ref: 'read/user/userslug/chats/roomid.yaml'
|
||||
"/api/user/{userslug}/chats/{roomid}/{index}":
|
||||
$ref: 'read/user/userslug/chats/roomid.yaml'
|
||||
"/api/chats/{roomid}":
|
||||
$ref: 'read/chats/roomid.yaml'
|
||||
"/api/chats/{roomid}/{index}":
|
||||
$ref: 'read/chats/roomid.yaml'
|
||||
"/api/message/{mid}":
|
||||
$ref: 'read/message/mid.yaml'
|
||||
/api/groups:
|
||||
$ref: 'read/groups.yaml'
|
||||
"/api/groups/{slug}":
|
||||
|
||||
@@ -10,6 +10,12 @@ get:
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
- name: index
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
responses:
|
||||
"200":
|
||||
description: "Chat identifier resolved"
|
||||
|
||||
19
public/openapi/read/message/mid.yaml
Normal file
19
public/openapi/read/message/mid.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
get:
|
||||
tags:
|
||||
- shorthand
|
||||
summary: Access a specific chat message
|
||||
description: This route comes in handy when all you have is the `mid`, and you want to redirect users to the canonical URL for the chat message, with the appropriate user slug and room id
|
||||
parameters:
|
||||
- name: mid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
responses:
|
||||
"200":
|
||||
description: "Canonical URL of chat message"
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
@@ -15,6 +15,12 @@ get:
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
- name: index
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
@@ -32,6 +38,11 @@ get:
|
||||
type: boolean
|
||||
userCount:
|
||||
type: number
|
||||
messageCount:
|
||||
type: number
|
||||
scrollToIndex:
|
||||
type: number
|
||||
nullable: true
|
||||
icon:
|
||||
type: string
|
||||
groups:
|
||||
@@ -187,6 +198,8 @@ get:
|
||||
type: boolean
|
||||
userCount:
|
||||
type: number
|
||||
messageCount:
|
||||
type: number
|
||||
groups:
|
||||
type: array
|
||||
timestamp:
|
||||
@@ -238,6 +251,8 @@ get:
|
||||
teaser:
|
||||
type: object
|
||||
properties:
|
||||
roomId:
|
||||
type: number
|
||||
fromuid:
|
||||
type: number
|
||||
content:
|
||||
|
||||
@@ -69,9 +69,19 @@ define('forum/chats', [
|
||||
}
|
||||
|
||||
Chats.initialised = true;
|
||||
const changeContentEl = $('[component="chat/message/content"]');
|
||||
messages.wrapImagesInLinks(changeContentEl);
|
||||
messages.scrollToBottomAfterImageLoad(changeContentEl);
|
||||
const chatContentEl = $('[component="chat/message/content"]');
|
||||
messages.wrapImagesInLinks(chatContentEl);
|
||||
if (ajaxify.data.scrollToIndex) {
|
||||
messages.toggleScrollUpAlert(chatContentEl);
|
||||
const scrollToEl = chatContentEl.find(`[data-index="${ajaxify.data.scrollToIndex - 1}"]`);
|
||||
if (scrollToEl.length) {
|
||||
chatContentEl.scrollTop(
|
||||
chatContentEl.scrollTop() - chatContentEl.offset().top + scrollToEl.offset().top
|
||||
);
|
||||
}
|
||||
} else {
|
||||
messages.scrollToBottomAfterImageLoad(chatContentEl);
|
||||
}
|
||||
create.init();
|
||||
|
||||
hooks.fire('action:chat.loaded', $('.chats-full'));
|
||||
@@ -90,12 +100,13 @@ define('forum/chats', [
|
||||
Chats.addLeaveHandler(roomId, chatControls.find('[data-action="leave"]'));
|
||||
Chats.addDeleteHandler(roomId, chatControls.find('[data-action="delete"]'));
|
||||
Chats.addScrollHandler(roomId, ajaxify.data.uid, chatMessageContent);
|
||||
Chats.addScrollBottomHandler(chatMessageContent);
|
||||
Chats.addScrollBottomHandler(roomId, chatMessageContent);
|
||||
Chats.addParentHandler(mainWrapper);
|
||||
Chats.addCharactersLeftHandler(mainWrapper);
|
||||
Chats.addTextareaResizeHandler(mainWrapper);
|
||||
Chats.addTypingHandler(mainWrapper, roomId);
|
||||
Chats.addIPHandler(mainWrapper);
|
||||
Chats.addCopyLinkHandler(mainWrapper);
|
||||
Chats.createAutoComplete(roomId, $('[component="chat/input"]'));
|
||||
Chats.addUploadHandler({
|
||||
dragDropAreaEl: $('.chats-full'),
|
||||
@@ -230,6 +241,19 @@ define('forum/chats', [
|
||||
});
|
||||
};
|
||||
|
||||
Chats.addCopyLinkHandler = function (container) {
|
||||
container.off('click', '[data-action="copy-link"]')
|
||||
.on('click', '[data-action="copy-link"]', function () {
|
||||
const copyEl = $(this);
|
||||
const mid = copyEl.attr('data-mid');
|
||||
if (mid) {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/message/${mid}`);
|
||||
copyEl.find('i').addClass('fa-check').removeClass('fa-link');
|
||||
setTimeout(() => copyEl.find('i').removeClass('fa-check').addClass('fa-link'), 2000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Chats.addPopoutHandler = function () {
|
||||
$('[data-action="pop-out"]').on('click', function () {
|
||||
const text = components.get('chat/input').val();
|
||||
@@ -252,49 +276,82 @@ define('forum/chats', [
|
||||
|
||||
Chats.addScrollHandler = function (roomId, uid, el) {
|
||||
let loading = false;
|
||||
let previousScrollTop = el.scrollTop();
|
||||
let currentScrollTop = previousScrollTop;
|
||||
el.off('scroll').on('scroll', utils.debounce(function () {
|
||||
if (parseInt(el.attr('data-ignore-next-scroll'), 10) === 1) {
|
||||
el.removeAttr('data-ignore-next-scroll');
|
||||
previousScrollTop = el.scrollTop();
|
||||
return;
|
||||
}
|
||||
messages.toggleScrollUpAlert(el);
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
currentScrollTop = el.scrollTop();
|
||||
|
||||
const top = (el[0].scrollHeight - el.height()) * 0.1;
|
||||
if (el.scrollTop() >= top) {
|
||||
const direction = currentScrollTop > previousScrollTop ? 1 : -1;
|
||||
previousScrollTop = currentScrollTop;
|
||||
const scrollPercent = 100 * (currentScrollTop / (el[0].scrollHeight - el.height()));
|
||||
const top = 15;
|
||||
const bottom = 85;
|
||||
|
||||
if (direction === 1 && !ajaxify.data.scrollToIndex) {
|
||||
// dont trigger infinitescroll if there is no /index in url
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
const start = parseInt(el.children('[data-mid]').length, 10);
|
||||
api.get(`/chats/${roomId}/messages`, { uid, start }).then((data) => {
|
||||
data = data.messages;
|
||||
|
||||
if (!data) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
data = data.filter(function (chatMsg) {
|
||||
return !$('[component="chat/message"][data-mid="' + chatMsg.messageId + '"]').length;
|
||||
});
|
||||
if (!data.length) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
messages.parseMessage(data, function (html) {
|
||||
const currentScrollTop = el.scrollTop();
|
||||
const previousHeight = el[0].scrollHeight;
|
||||
el.prepend(html);
|
||||
messages.onMessagesAddedToDom(html);
|
||||
el.scrollTop((el[0].scrollHeight - previousHeight) + currentScrollTop);
|
||||
loading = false;
|
||||
});
|
||||
}).catch(alerts.error);
|
||||
if ((scrollPercent < top && direction === -1) || (scrollPercent > bottom && direction === 1)) {
|
||||
loading = true;
|
||||
|
||||
const msgEls = el.children('[data-mid]').not('.new');
|
||||
const afterEl = direction > 0 ? msgEls.last() : msgEls.first();
|
||||
const start = parseInt(afterEl.attr('data-index'), 10) || 0;
|
||||
|
||||
api.get(`/chats/${roomId}/messages`, { uid, start, direction }).then((data) => {
|
||||
let messageData = data.messages;
|
||||
if (!messageData) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
messageData = messageData.filter(function (chatMsg) {
|
||||
const msgOnDom = el.find('[component="chat/message"][data-mid="' + chatMsg.messageId + '"]');
|
||||
msgOnDom.removeClass('new');
|
||||
return !msgOnDom.length;
|
||||
});
|
||||
if (!messageData.length) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
messages.parseMessage(messageData, function (html) {
|
||||
el.attr('data-ignore-next-scroll', 1);
|
||||
if (direction > 0) {
|
||||
html.insertAfter(afterEl);
|
||||
messages.onMessagesAddedToDom(html);
|
||||
} else {
|
||||
const currentScrollTop = el.scrollTop();
|
||||
const previousHeight = el[0].scrollHeight;
|
||||
el.prepend(html);
|
||||
messages.onMessagesAddedToDom(html);
|
||||
el.scrollTop((el[0].scrollHeight - previousHeight) + currentScrollTop);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
}).catch(alerts.error);
|
||||
}
|
||||
}, 100));
|
||||
};
|
||||
|
||||
Chats.addScrollBottomHandler = function (chatContent) {
|
||||
Chats.addScrollBottomHandler = function (roomId, chatContent) {
|
||||
chatContent.parents('[component="chat/message/window"]')
|
||||
.find('[component="chat/messages/scroll-up-alert"]')
|
||||
.off('click').on('click', function () {
|
||||
messages.scrollToBottom(chatContent);
|
||||
if (ajaxify.data.scrollToIndex && parseInt(ajaxify.data.roomId, 10) === parseInt(roomId, 10)) {
|
||||
Chats.switchChat(roomId);
|
||||
} else {
|
||||
messages.scrollToBottom(chatContent);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ define('forum/chats/messages', [
|
||||
if (!Array.isArray(data)) {
|
||||
data.newSet = data.toMid || lastSpeaker !== parseInt(data.fromuid, 10) ||
|
||||
parseInt(data.timestamp, 10) > parseInt(lasttimestamp, 10) + (1000 * 60 * 3);
|
||||
data.index = parseInt(lastMsgEl.attr('data-index'), 10) + 1;
|
||||
}
|
||||
|
||||
messages.parseMessage(data, function (html) {
|
||||
@@ -93,6 +94,7 @@ define('forum/chats/messages', [
|
||||
function onMessagesParsed(chatContentEl, html, msgData) {
|
||||
const newMessage = $(html);
|
||||
const isAtBottom = messages.isAtBottom(chatContentEl);
|
||||
newMessage.addClass('new');
|
||||
newMessage.appendTo(chatContentEl);
|
||||
messages.onMessagesAddedToDom(newMessage);
|
||||
if (isAtBottom || msgData.self) {
|
||||
@@ -102,6 +104,7 @@ define('forum/chats/messages', [
|
||||
if (chatMsgEls.length > 150) {
|
||||
const removeCount = chatMsgEls.length - 150;
|
||||
chatMsgEls.slice(0, removeCount).remove();
|
||||
chatContentEl.find('[data-mid].new').removeClass('new');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +153,7 @@ define('forum/chats/messages', [
|
||||
|
||||
messages.scrollToBottom = function (containerEl) {
|
||||
if (containerEl && containerEl.length) {
|
||||
containerEl.attr('data-ignore-next-scroll', 1);
|
||||
containerEl.scrollTop(containerEl[0].scrollHeight - containerEl.height());
|
||||
containerEl.parents('[component="chat/message/window"]')
|
||||
.find('[component="chat/messages/scroll-up-alert"]')
|
||||
|
||||
@@ -399,7 +399,7 @@ define('chat', [
|
||||
Chats.createAutoComplete(roomId, chatModal.find('[component="chat/input"]'));
|
||||
|
||||
Chats.addScrollHandler(roomId, data.uid, chatModal.find('[component="chat/message/content"]'));
|
||||
Chats.addScrollBottomHandler(chatModal.find('[component="chat/message/content"]'));
|
||||
Chats.addScrollBottomHandler(roomId, chatModal.find('[component="chat/message/content"]'));
|
||||
Chats.addParentHandler(chatModal.find('[component="chat/message/content"]'));
|
||||
Chats.addCharactersLeftHandler(chatModal);
|
||||
Chats.addTextareaResizeHandler(chatModal);
|
||||
|
||||
@@ -244,13 +244,29 @@ chatsAPI.kick = async (caller, data) => {
|
||||
return chatsAPI.users(caller, data);
|
||||
};
|
||||
|
||||
chatsAPI.listMessages = async (caller, { uid, roomId, start }) => {
|
||||
chatsAPI.listMessages = async (caller, { uid, roomId, start, direction = null }) => {
|
||||
const count = 50;
|
||||
let stop = start + count - 1;
|
||||
if (direction === 1 || direction === -1) {
|
||||
const msgCount = await db.getObjectField(`chat:room:${roomId}`, 'messageCount');
|
||||
start = msgCount - start;
|
||||
if (direction === 1) {
|
||||
start -= count + 1;
|
||||
}
|
||||
stop = start + count - 1;
|
||||
start = Math.max(0, start);
|
||||
if (stop <= -1) {
|
||||
return { messages: [] };
|
||||
}
|
||||
stop = Math.max(0, stop);
|
||||
}
|
||||
|
||||
const messages = await messaging.getMessages({
|
||||
callerUid: caller.uid,
|
||||
uid,
|
||||
roomId,
|
||||
start,
|
||||
count: 50,
|
||||
count: stop - start + 1,
|
||||
});
|
||||
|
||||
return { messages };
|
||||
|
||||
@@ -48,7 +48,19 @@ chatsController.get = async function (req, res, next) {
|
||||
return res.render('chats', payload);
|
||||
}
|
||||
|
||||
const room = await messaging.loadRoom(req.uid, { uid: uid, roomId: req.params.roomid });
|
||||
const { index } = req.params;
|
||||
let start = 0;
|
||||
payload.scrollToIndex = null;
|
||||
if (index) {
|
||||
const msgCount = await db.getObjectField(`chat:room:${req.params.roomid}`, 'messageCount');
|
||||
start = Math.max(0, parseInt(msgCount, 10) - index - 49);
|
||||
payload.scrollToIndex = Math.min(msgCount, Math.max(0, parseInt(index, 10) || 1));
|
||||
}
|
||||
const room = await messaging.loadRoom(req.uid, {
|
||||
uid: uid,
|
||||
roomId: req.params.roomid,
|
||||
start: start,
|
||||
});
|
||||
if (!room) {
|
||||
return next();
|
||||
}
|
||||
@@ -72,5 +84,26 @@ chatsController.redirectToChat = async function (req, res, next) {
|
||||
return next();
|
||||
}
|
||||
const roomid = parseInt(req.params.roomid, 10);
|
||||
helpers.redirect(res, `/user/${userslug}/chats${roomid ? `/${roomid}` : ''}`);
|
||||
const index = parseInt(req.params.index, 10);
|
||||
helpers.redirect(res, `/user/${userslug}/chats${roomid ? `/${roomid}` : ''}${index ? `/${index}` : ''}`);
|
||||
};
|
||||
|
||||
chatsController.redirectToMessage = async function (req, res, next) {
|
||||
const mid = parseInt(req.params.mid, 10);
|
||||
if (!mid) {
|
||||
return next();
|
||||
}
|
||||
const [userslug, roomId] = await Promise.all([
|
||||
user.getUserField(req.uid, 'userslug'),
|
||||
messaging.getMessageField(mid, 'roomId'),
|
||||
]);
|
||||
if (!userslug || !roomId) {
|
||||
return next();
|
||||
}
|
||||
const index = await db.sortedSetRank(`chat:room:${roomId}:mids`, mid);
|
||||
if (!(parseInt(index, 10) >= 0)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
helpers.redirect(res, `/user/${userslug}/chats/${roomId}${index ? `/${index + 1}` : ''}`, true);
|
||||
};
|
||||
|
||||
@@ -110,7 +110,10 @@ Chats.messages.list = async (req, res) => {
|
||||
const uid = req.query.uid || req.uid;
|
||||
const { roomId } = req.params;
|
||||
const start = parseInt(req.query.start, 10) || 0;
|
||||
const { messages } = await api.chats.listMessages(req, { uid, roomId, start });
|
||||
const direction = parseInt(req.query.direction, 10) || null;
|
||||
const { messages } = await api.chats.listMessages(req, {
|
||||
uid, roomId, start, direction,
|
||||
});
|
||||
|
||||
helpers.formatApiResponse(200, res, { messages });
|
||||
};
|
||||
|
||||
@@ -127,5 +127,6 @@ module.exports = function (Messaging) {
|
||||
|
||||
Messaging.addMessageToRoom = async (roomId, mid, timestamp) => {
|
||||
await db.sortedSetAdd(`chat:room:${roomId}:mids`, timestamp, mid);
|
||||
await db.incrObjectField(`chat:room:${roomId}`, 'messageCount');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,13 +43,17 @@ Messaging.getMessages = async (params) => {
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
const mids = await getMessageIds(roomId, uid, start, stop);
|
||||
const [mids, messageCount] = await Promise.all([
|
||||
getMessageIds(roomId, uid, start, stop),
|
||||
db.getObjectField(`chat:room:${roomId}`, 'messageCount'),
|
||||
]);
|
||||
if (!mids.length) {
|
||||
return [];
|
||||
}
|
||||
const count = parseInt(messageCount, 10) || 0;
|
||||
const indices = {};
|
||||
mids.forEach((mid, index) => {
|
||||
indices[mid] = start + index;
|
||||
indices[mid] = count - start - index - 1;
|
||||
});
|
||||
mids.reverse();
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const roomUidCache = cacheCreate({
|
||||
});
|
||||
|
||||
const intFields = [
|
||||
'roomId', 'timestamp', 'userCount',
|
||||
'roomId', 'timestamp', 'userCount', 'messageCount',
|
||||
];
|
||||
|
||||
module.exports = function (Messaging) {
|
||||
@@ -86,6 +86,7 @@ module.exports = function (Messaging) {
|
||||
roomId: roomId,
|
||||
timestamp: now,
|
||||
notificationSetting: data.notificationSetting,
|
||||
messageCount: 0,
|
||||
};
|
||||
|
||||
if (data.hasOwnProperty('roomName') && data.roomName) {
|
||||
@@ -500,6 +501,7 @@ module.exports = function (Messaging) {
|
||||
Messaging.getUsersInRoomFromSet(`chat:room:${roomId}:uids:online`, roomId, 0, 39, true),
|
||||
Messaging.getMessages({
|
||||
callerUid: uid,
|
||||
start: data.start || 0,
|
||||
uid: data.uid || uid,
|
||||
roomId: roomId,
|
||||
isNew: false,
|
||||
|
||||
@@ -48,6 +48,8 @@ module.exports = function (app, name, middleware, controllers) {
|
||||
setupPageRoute(app, `/${name}/:userslug/sessions`, accountMiddlewares, controllers.accounts.sessions.get);
|
||||
|
||||
setupPageRoute(app, '/notifications', [middleware.ensureLoggedIn], controllers.accounts.notifications.get);
|
||||
setupPageRoute(app, `/${name}/:userslug/chats/:roomid?`, [middleware.exposeUid, middleware.canViewUsers], controllers.accounts.chats.get);
|
||||
setupPageRoute(app, '/chats/:roomid?', [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToChat);
|
||||
setupPageRoute(app, `/${name}/:userslug/chats/:roomid?/:index?`, [middleware.exposeUid, middleware.canViewUsers], controllers.accounts.chats.get);
|
||||
setupPageRoute(app, '/chats/:roomid?/:index?', [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToChat);
|
||||
|
||||
setupPageRoute(app, `/message/:mid`, [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToMessage);
|
||||
};
|
||||
|
||||
20
src/upgrades/3.6.0/chat_message_counts.js
Normal file
20
src/upgrades/3.6.0/chat_message_counts.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
|
||||
module.exports = {
|
||||
name: 'Set messageCount on chat rooms',
|
||||
timestamp: Date.UTC(2023, 6, 27),
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
const allRoomIds = await db.getSortedSetRange(`chat:rooms`, 0, -1);
|
||||
progress.total = allRoomIds.length;
|
||||
for (const roomId of allRoomIds) {
|
||||
const count = await db.sortedSetCard(`chat:room:${roomId}:mids`);
|
||||
await db.setObject(`chat:room:${roomId}`, { messageCount: count });
|
||||
progress.incr(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -281,7 +281,7 @@ describe('API', async () => {
|
||||
await flags.create('post', 2, unprivUid, 'sample reasons', Date.now()); // for testing flag notes (since flag 1 deleted)
|
||||
|
||||
// Create a new chat room
|
||||
await messaging.newRoom(1, { uids: [2] });
|
||||
await messaging.newRoom(adminUid, { uids: [unprivUid] });
|
||||
|
||||
// Create an empty file to test DELETE /files and thumb deletion
|
||||
fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.txt'), 'w'));
|
||||
|
||||
Reference in New Issue
Block a user