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:
Barış Soner Uşaklı
2023-10-31 10:15:06 -04:00
committed by GitHub
parent 7a8c27bf90
commit 4c4f3ac983
18 changed files with 236 additions and 44 deletions

View File

@@ -68,6 +68,7 @@
"chat.in-room": "In this room", "chat.in-room": "In this room",
"chat.kick": "Kick", "chat.kick": "Kick",
"chat.show-ip": "Show IP", "chat.show-ip": "Show IP",
"chat.copy-link": "Copy link",
"chat.owner": "Room Owner", "chat.owner": "Room Owner",
"chat.grant-rescind-ownership": "Grant/Rescind Ownership", "chat.grant-rescind-ownership": "Grant/Rescind Ownership",

View File

@@ -16,6 +16,9 @@ RoomObject:
userCount: userCount:
type: number type: number
description: number of users in this chat room description: number of users in this chat room
messageCount:
type: number
description: number of messages sent in this chat room
groups: groups:
type: array type: array
description: list of groups that can access the room description: list of groups that can access the room

View File

@@ -310,8 +310,14 @@ paths:
$ref: 'read/notifications.yaml' $ref: 'read/notifications.yaml'
"/api/user/{userslug}/chats/{roomid}": "/api/user/{userslug}/chats/{roomid}":
$ref: 'read/user/userslug/chats/roomid.yaml' $ref: 'read/user/userslug/chats/roomid.yaml'
"/api/user/{userslug}/chats/{roomid}/{index}":
$ref: 'read/user/userslug/chats/roomid.yaml'
"/api/chats/{roomid}": "/api/chats/{roomid}":
$ref: 'read/chats/roomid.yaml' $ref: 'read/chats/roomid.yaml'
"/api/chats/{roomid}/{index}":
$ref: 'read/chats/roomid.yaml'
"/api/message/{mid}":
$ref: 'read/message/mid.yaml'
/api/groups: /api/groups:
$ref: 'read/groups.yaml' $ref: 'read/groups.yaml'
"/api/groups/{slug}": "/api/groups/{slug}":

View File

@@ -10,6 +10,12 @@ get:
schema: schema:
type: string type: string
example: 1 example: 1
- name: index
in: path
required: true
schema:
type: string
example: 1
responses: responses:
"200": "200":
description: "Chat identifier resolved" description: "Chat identifier resolved"

View 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

View File

@@ -15,6 +15,12 @@ get:
schema: schema:
type: string type: string
example: 1 example: 1
- name: index
in: path
required: true
schema:
type: string
example: 1
responses: responses:
"200": "200":
description: "" description: ""
@@ -32,6 +38,11 @@ get:
type: boolean type: boolean
userCount: userCount:
type: number type: number
messageCount:
type: number
scrollToIndex:
type: number
nullable: true
icon: icon:
type: string type: string
groups: groups:
@@ -187,6 +198,8 @@ get:
type: boolean type: boolean
userCount: userCount:
type: number type: number
messageCount:
type: number
groups: groups:
type: array type: array
timestamp: timestamp:
@@ -238,6 +251,8 @@ get:
teaser: teaser:
type: object type: object
properties: properties:
roomId:
type: number
fromuid: fromuid:
type: number type: number
content: content:

View File

@@ -69,9 +69,19 @@ define('forum/chats', [
} }
Chats.initialised = true; Chats.initialised = true;
const changeContentEl = $('[component="chat/message/content"]'); const chatContentEl = $('[component="chat/message/content"]');
messages.wrapImagesInLinks(changeContentEl); messages.wrapImagesInLinks(chatContentEl);
messages.scrollToBottomAfterImageLoad(changeContentEl); 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(); create.init();
hooks.fire('action:chat.loaded', $('.chats-full')); hooks.fire('action:chat.loaded', $('.chats-full'));
@@ -90,12 +100,13 @@ define('forum/chats', [
Chats.addLeaveHandler(roomId, chatControls.find('[data-action="leave"]')); Chats.addLeaveHandler(roomId, chatControls.find('[data-action="leave"]'));
Chats.addDeleteHandler(roomId, chatControls.find('[data-action="delete"]')); Chats.addDeleteHandler(roomId, chatControls.find('[data-action="delete"]'));
Chats.addScrollHandler(roomId, ajaxify.data.uid, chatMessageContent); Chats.addScrollHandler(roomId, ajaxify.data.uid, chatMessageContent);
Chats.addScrollBottomHandler(chatMessageContent); Chats.addScrollBottomHandler(roomId, chatMessageContent);
Chats.addParentHandler(mainWrapper); Chats.addParentHandler(mainWrapper);
Chats.addCharactersLeftHandler(mainWrapper); Chats.addCharactersLeftHandler(mainWrapper);
Chats.addTextareaResizeHandler(mainWrapper); Chats.addTextareaResizeHandler(mainWrapper);
Chats.addTypingHandler(mainWrapper, roomId); Chats.addTypingHandler(mainWrapper, roomId);
Chats.addIPHandler(mainWrapper); Chats.addIPHandler(mainWrapper);
Chats.addCopyLinkHandler(mainWrapper);
Chats.createAutoComplete(roomId, $('[component="chat/input"]')); Chats.createAutoComplete(roomId, $('[component="chat/input"]'));
Chats.addUploadHandler({ Chats.addUploadHandler({
dragDropAreaEl: $('.chats-full'), 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 () { Chats.addPopoutHandler = function () {
$('[data-action="pop-out"]').on('click', function () { $('[data-action="pop-out"]').on('click', function () {
const text = components.get('chat/input').val(); const text = components.get('chat/input').val();
@@ -252,49 +276,82 @@ define('forum/chats', [
Chats.addScrollHandler = function (roomId, uid, el) { Chats.addScrollHandler = function (roomId, uid, el) {
let loading = false; let loading = false;
let previousScrollTop = el.scrollTop();
let currentScrollTop = previousScrollTop;
el.off('scroll').on('scroll', utils.debounce(function () { 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); messages.toggleScrollUpAlert(el);
if (loading) { if (loading) {
return; return;
} }
currentScrollTop = el.scrollTop();
const top = (el[0].scrollHeight - el.height()) * 0.1; const direction = currentScrollTop > previousScrollTop ? 1 : -1;
if (el.scrollTop() >= top) { 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; return;
} }
if ((scrollPercent < top && direction === -1) || (scrollPercent > bottom && direction === 1)) {
loading = true; 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) { 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; loading = false;
return; return;
} }
data = data.filter(function (chatMsg) { messageData = messageData.filter(function (chatMsg) {
return !$('[component="chat/message"][data-mid="' + chatMsg.messageId + '"]').length; const msgOnDom = el.find('[component="chat/message"][data-mid="' + chatMsg.messageId + '"]');
msgOnDom.removeClass('new');
return !msgOnDom.length;
}); });
if (!data.length) { if (!messageData.length) {
loading = false; loading = false;
return; return;
} }
messages.parseMessage(data, function (html) { 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 currentScrollTop = el.scrollTop();
const previousHeight = el[0].scrollHeight; const previousHeight = el[0].scrollHeight;
el.prepend(html); el.prepend(html);
messages.onMessagesAddedToDom(html); messages.onMessagesAddedToDom(html);
el.scrollTop((el[0].scrollHeight - previousHeight) + currentScrollTop); el.scrollTop((el[0].scrollHeight - previousHeight) + currentScrollTop);
}
loading = false; loading = false;
}); });
}).catch(alerts.error); }).catch(alerts.error);
}
}, 100)); }, 100));
}; };
Chats.addScrollBottomHandler = function (chatContent) { Chats.addScrollBottomHandler = function (roomId, chatContent) {
chatContent.parents('[component="chat/message/window"]') chatContent.parents('[component="chat/message/window"]')
.find('[component="chat/messages/scroll-up-alert"]') .find('[component="chat/messages/scroll-up-alert"]')
.off('click').on('click', function () { .off('click').on('click', function () {
if (ajaxify.data.scrollToIndex && parseInt(ajaxify.data.roomId, 10) === parseInt(roomId, 10)) {
Chats.switchChat(roomId);
} else {
messages.scrollToBottom(chatContent); messages.scrollToBottom(chatContent);
}
}); });
}; };

View File

@@ -83,6 +83,7 @@ define('forum/chats/messages', [
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
data.newSet = data.toMid || lastSpeaker !== parseInt(data.fromuid, 10) || data.newSet = data.toMid || lastSpeaker !== parseInt(data.fromuid, 10) ||
parseInt(data.timestamp, 10) > parseInt(lasttimestamp, 10) + (1000 * 60 * 3); parseInt(data.timestamp, 10) > parseInt(lasttimestamp, 10) + (1000 * 60 * 3);
data.index = parseInt(lastMsgEl.attr('data-index'), 10) + 1;
} }
messages.parseMessage(data, function (html) { messages.parseMessage(data, function (html) {
@@ -93,6 +94,7 @@ define('forum/chats/messages', [
function onMessagesParsed(chatContentEl, html, msgData) { function onMessagesParsed(chatContentEl, html, msgData) {
const newMessage = $(html); const newMessage = $(html);
const isAtBottom = messages.isAtBottom(chatContentEl); const isAtBottom = messages.isAtBottom(chatContentEl);
newMessage.addClass('new');
newMessage.appendTo(chatContentEl); newMessage.appendTo(chatContentEl);
messages.onMessagesAddedToDom(newMessage); messages.onMessagesAddedToDom(newMessage);
if (isAtBottom || msgData.self) { if (isAtBottom || msgData.self) {
@@ -102,6 +104,7 @@ define('forum/chats/messages', [
if (chatMsgEls.length > 150) { if (chatMsgEls.length > 150) {
const removeCount = chatMsgEls.length - 150; const removeCount = chatMsgEls.length - 150;
chatMsgEls.slice(0, removeCount).remove(); chatMsgEls.slice(0, removeCount).remove();
chatContentEl.find('[data-mid].new').removeClass('new');
} }
} }
@@ -150,6 +153,7 @@ define('forum/chats/messages', [
messages.scrollToBottom = function (containerEl) { messages.scrollToBottom = function (containerEl) {
if (containerEl && containerEl.length) { if (containerEl && containerEl.length) {
containerEl.attr('data-ignore-next-scroll', 1);
containerEl.scrollTop(containerEl[0].scrollHeight - containerEl.height()); containerEl.scrollTop(containerEl[0].scrollHeight - containerEl.height());
containerEl.parents('[component="chat/message/window"]') containerEl.parents('[component="chat/message/window"]')
.find('[component="chat/messages/scroll-up-alert"]') .find('[component="chat/messages/scroll-up-alert"]')

View File

@@ -399,7 +399,7 @@ define('chat', [
Chats.createAutoComplete(roomId, chatModal.find('[component="chat/input"]')); Chats.createAutoComplete(roomId, chatModal.find('[component="chat/input"]'));
Chats.addScrollHandler(roomId, data.uid, chatModal.find('[component="chat/message/content"]')); 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.addParentHandler(chatModal.find('[component="chat/message/content"]'));
Chats.addCharactersLeftHandler(chatModal); Chats.addCharactersLeftHandler(chatModal);
Chats.addTextareaResizeHandler(chatModal); Chats.addTextareaResizeHandler(chatModal);

View File

@@ -244,13 +244,29 @@ chatsAPI.kick = async (caller, data) => {
return chatsAPI.users(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({ const messages = await messaging.getMessages({
callerUid: caller.uid, callerUid: caller.uid,
uid, uid,
roomId, roomId,
start, start,
count: 50, count: stop - start + 1,
}); });
return { messages }; return { messages };

View File

@@ -48,7 +48,19 @@ chatsController.get = async function (req, res, next) {
return res.render('chats', payload); 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) { if (!room) {
return next(); return next();
} }
@@ -72,5 +84,26 @@ chatsController.redirectToChat = async function (req, res, next) {
return next(); return next();
} }
const roomid = parseInt(req.params.roomid, 10); 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);
}; };

View File

@@ -110,7 +110,10 @@ Chats.messages.list = async (req, res) => {
const uid = req.query.uid || req.uid; const uid = req.query.uid || req.uid;
const { roomId } = req.params; const { roomId } = req.params;
const start = parseInt(req.query.start, 10) || 0; 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 }); helpers.formatApiResponse(200, res, { messages });
}; };

View File

@@ -127,5 +127,6 @@ module.exports = function (Messaging) {
Messaging.addMessageToRoom = async (roomId, mid, timestamp) => { Messaging.addMessageToRoom = async (roomId, mid, timestamp) => {
await db.sortedSetAdd(`chat:room:${roomId}:mids`, timestamp, mid); await db.sortedSetAdd(`chat:room:${roomId}:mids`, timestamp, mid);
await db.incrObjectField(`chat:room:${roomId}`, 'messageCount');
}; };
}; };

View File

@@ -43,13 +43,17 @@ Messaging.getMessages = async (params) => {
if (!ok) { if (!ok) {
return; 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) { if (!mids.length) {
return []; return [];
} }
const count = parseInt(messageCount, 10) || 0;
const indices = {}; const indices = {};
mids.forEach((mid, index) => { mids.forEach((mid, index) => {
indices[mid] = start + index; indices[mid] = count - start - index - 1;
}); });
mids.reverse(); mids.reverse();

View File

@@ -21,7 +21,7 @@ const roomUidCache = cacheCreate({
}); });
const intFields = [ const intFields = [
'roomId', 'timestamp', 'userCount', 'roomId', 'timestamp', 'userCount', 'messageCount',
]; ];
module.exports = function (Messaging) { module.exports = function (Messaging) {
@@ -86,6 +86,7 @@ module.exports = function (Messaging) {
roomId: roomId, roomId: roomId,
timestamp: now, timestamp: now,
notificationSetting: data.notificationSetting, notificationSetting: data.notificationSetting,
messageCount: 0,
}; };
if (data.hasOwnProperty('roomName') && data.roomName) { 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.getUsersInRoomFromSet(`chat:room:${roomId}:uids:online`, roomId, 0, 39, true),
Messaging.getMessages({ Messaging.getMessages({
callerUid: uid, callerUid: uid,
start: data.start || 0,
uid: data.uid || uid, uid: data.uid || uid,
roomId: roomId, roomId: roomId,
isNew: false, isNew: false,

View File

@@ -48,6 +48,8 @@ module.exports = function (app, name, middleware, controllers) {
setupPageRoute(app, `/${name}/:userslug/sessions`, accountMiddlewares, controllers.accounts.sessions.get); setupPageRoute(app, `/${name}/:userslug/sessions`, accountMiddlewares, controllers.accounts.sessions.get);
setupPageRoute(app, '/notifications', [middleware.ensureLoggedIn], controllers.accounts.notifications.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, `/${name}/:userslug/chats/:roomid?/:index?`, [middleware.exposeUid, middleware.canViewUsers], controllers.accounts.chats.get);
setupPageRoute(app, '/chats/:roomid?', [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToChat); setupPageRoute(app, '/chats/:roomid?/:index?', [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToChat);
setupPageRoute(app, `/message/:mid`, [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToMessage);
}; };

View 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);
}
},
};

View File

@@ -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) await flags.create('post', 2, unprivUid, 'sample reasons', Date.now()); // for testing flag notes (since flag 1 deleted)
// Create a new chat room // 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 // 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')); fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.txt'), 'w'));