mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Merge pull request #18710 from overleaf/mj-web-chat-send-thread-data
[chat+web] Inform frontend when duplicating threads GitOrigin-RevId: 285afee8f5a016a8e7ac58e9538cc3ec8362681d
This commit is contained in:
parent
0f869f9059
commit
110b83aea0
11 changed files with 312 additions and 158 deletions
|
@ -86,6 +86,10 @@ export async function duplicateCommentThreads(context) {
|
|||
return await callMessageHttpController(context, _duplicateCommentThreads)
|
||||
}
|
||||
|
||||
export async function generateThreadData(context) {
|
||||
return await callMessageHttpController(context, _generateThreadData)
|
||||
}
|
||||
|
||||
export async function getStatus(context) {
|
||||
const message = 'chat is alive'
|
||||
context.res.status(200).setBody(message)
|
||||
|
@ -124,6 +128,18 @@ const _getAllThreads = async (req, res) => {
|
|||
res.json(threads)
|
||||
}
|
||||
|
||||
const _generateThreadData = async (req, res) => {
|
||||
const { projectId } = req.params
|
||||
const { threads } = req.body
|
||||
logger.debug({ projectId }, 'getting all threads')
|
||||
const rooms = await ThreadManager.findThreadsById(projectId, threads)
|
||||
const roomIds = rooms.map(r => r._id)
|
||||
const messages = await MessageManager.findAllMessagesInRooms(roomIds)
|
||||
logger.debug({ rooms, messages }, 'looked up messages in the rooms')
|
||||
const threadData = MessageFormatter.groupMessagesByThreads(rooms, messages)
|
||||
res.json(threadData)
|
||||
}
|
||||
|
||||
const _resolveThread = async (req, res) => {
|
||||
const { projectId, threadId } = req.params
|
||||
const { user_id: userId } = req.body
|
||||
|
|
|
@ -146,3 +146,12 @@ export async function duplicateThread(projectId, threadId) {
|
|||
newRoom._id = confirmation.insertedId
|
||||
return { oldRoom: room, newRoom }
|
||||
}
|
||||
|
||||
export async function findThreadsById(projectId, threadIds) {
|
||||
return await db.rooms
|
||||
.find({
|
||||
project_id: new ObjectId(projectId),
|
||||
thread_id: { $in: threadIds.map(id => new ObjectId(id)) },
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
|
|
@ -334,6 +334,33 @@ paths:
|
|||
type: object
|
||||
description: Mapping of old thread ids to their duplicated thread ids
|
||||
description: Duplicate a list of comment threads
|
||||
'/project/{projectId}/generate-thread-data':
|
||||
parameters:
|
||||
- schema:
|
||||
type: string
|
||||
name: projectId
|
||||
in: path
|
||||
required: true
|
||||
post:
|
||||
summary: Generate thread data to load into the frontend
|
||||
operationId: generateThreadData
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
threads:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: Load threads and generate a json blob containing all messages in all the threads
|
||||
components:
|
||||
schemas:
|
||||
Message:
|
||||
|
|
|
@ -109,6 +109,16 @@ async function duplicateCommentThreads(projectId, threads) {
|
|||
)
|
||||
}
|
||||
|
||||
async function generateThreadData(projectId, threads) {
|
||||
return await fetchJson(
|
||||
chatApiUrl(`/project/${projectId}/generate-thread-data`),
|
||||
{
|
||||
method: 'POST',
|
||||
json: { threads },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function chatApiUrl(path) {
|
||||
return new URL(path, settings.apis.chat.internal_url)
|
||||
}
|
||||
|
@ -126,6 +136,7 @@ module.exports = {
|
|||
deleteMessage: callbackify(deleteMessage),
|
||||
getResolvedThreadIds: callbackify(getResolvedThreadIds),
|
||||
duplicateCommentThreads: callbackify(duplicateCommentThreads),
|
||||
generateThreadData: callbackify(generateThreadData),
|
||||
promises: {
|
||||
getThreads,
|
||||
destroyProject,
|
||||
|
@ -139,5 +150,6 @@ module.exports = {
|
|||
deleteMessage,
|
||||
getResolvedThreadIds,
|
||||
duplicateCommentThreads,
|
||||
generateThreadData,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -18,7 +18,8 @@ const EditorRealTimeController = require('../Editor/EditorRealTimeController')
|
|||
const SessionManager = require('../Authentication/SessionManager')
|
||||
const UserInfoManager = require('../User/UserInfoManager')
|
||||
const UserInfoController = require('../User/UserInfoController')
|
||||
const async = require('async')
|
||||
const ChatManager = require('./ChatManager')
|
||||
const logger = require('@overleaf/logger')
|
||||
|
||||
module.exports = ChatController = {
|
||||
sendMessage(req, res, next) {
|
||||
|
@ -68,7 +69,7 @@ module.exports = ChatController = {
|
|||
if (err != null) {
|
||||
return next(err)
|
||||
}
|
||||
return ChatController._injectUserInfoIntoThreads(
|
||||
return ChatManager.injectUserInfoIntoThreads(
|
||||
{ global: { messages } },
|
||||
function (err) {
|
||||
if (err != null) {
|
||||
|
@ -80,55 +81,4 @@ module.exports = ChatController = {
|
|||
}
|
||||
)
|
||||
},
|
||||
|
||||
_injectUserInfoIntoThreads(threads, callback) {
|
||||
// There will be a lot of repitition of user_ids, so first build a list
|
||||
// of unique ones to perform db look ups on, then use these to populate the
|
||||
// user fields
|
||||
let message, thread, threadId, userId
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
const userIds = {}
|
||||
for (threadId in threads) {
|
||||
thread = threads[threadId]
|
||||
if (thread.resolved) {
|
||||
userIds[thread.resolved_by_user_id] = true
|
||||
}
|
||||
for (message of Array.from(thread.messages)) {
|
||||
userIds[message.user_id] = true
|
||||
}
|
||||
}
|
||||
|
||||
const jobs = []
|
||||
const users = {}
|
||||
for (userId in userIds) {
|
||||
const _ = userIds[userId]
|
||||
;(userId =>
|
||||
jobs.push(cb =>
|
||||
UserInfoManager.getPersonalInfo(userId, function (error, user) {
|
||||
if (error != null) return cb(error)
|
||||
user = UserInfoController.formatPersonalInfo(user)
|
||||
users[userId] = user
|
||||
cb()
|
||||
})
|
||||
))(userId)
|
||||
}
|
||||
|
||||
return async.series(jobs, function (error) {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
for (threadId in threads) {
|
||||
thread = threads[threadId]
|
||||
if (thread.resolved) {
|
||||
thread.resolved_by_user = users[thread.resolved_by_user_id]
|
||||
}
|
||||
for (message of Array.from(thread.messages)) {
|
||||
message.user = users[message.user_id]
|
||||
}
|
||||
}
|
||||
return callback(null, threads)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
61
services/web/app/src/Features/Chat/ChatManager.js
Normal file
61
services/web/app/src/Features/Chat/ChatManager.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
const async = require('async')
|
||||
const UserInfoManager = require('../User/UserInfoManager')
|
||||
const UserInfoController = require('../User/UserInfoController')
|
||||
const { promisify } = require('@overleaf/promise-utils')
|
||||
|
||||
function injectUserInfoIntoThreads(threads, callback) {
|
||||
// There will be a lot of repitition of user_ids, so first build a list
|
||||
// of unique ones to perform db look ups on, then use these to populate the
|
||||
// user fields
|
||||
let message, thread, threadId, userId
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
const userIds = {}
|
||||
for (threadId in threads) {
|
||||
thread = threads[threadId]
|
||||
if (thread.resolved) {
|
||||
userIds[thread.resolved_by_user_id] = true
|
||||
}
|
||||
for (message of Array.from(thread.messages)) {
|
||||
userIds[message.user_id] = true
|
||||
}
|
||||
}
|
||||
|
||||
const jobs = []
|
||||
const users = {}
|
||||
for (userId in userIds) {
|
||||
;(userId =>
|
||||
jobs.push(cb =>
|
||||
UserInfoManager.getPersonalInfo(userId, function (error, user) {
|
||||
if (error != null) return cb(error)
|
||||
user = UserInfoController.formatPersonalInfo(user)
|
||||
users[userId] = user
|
||||
cb()
|
||||
})
|
||||
))(userId)
|
||||
}
|
||||
|
||||
return async.series(jobs, function (error) {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
for (threadId in threads) {
|
||||
thread = threads[threadId]
|
||||
if (thread.resolved) {
|
||||
thread.resolved_by_user = users[thread.resolved_by_user_id]
|
||||
}
|
||||
for (message of Array.from(thread.messages)) {
|
||||
message.user = users[message.user_id]
|
||||
}
|
||||
}
|
||||
return callback(null, threads)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
injectUserInfoIntoThreads,
|
||||
promises: {
|
||||
injectUserInfoIntoThreads: promisify(injectUserInfoIntoThreads),
|
||||
},
|
||||
}
|
|
@ -12,6 +12,8 @@ const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandle
|
|||
const ChatApiHandler = require('../Chat/ChatApiHandler')
|
||||
const DocstoreManager = require('../Docstore/DocstoreManager')
|
||||
const logger = require('@overleaf/logger')
|
||||
const EditorRealTimeController = require('../Editor/EditorRealTimeController')
|
||||
const ChatManager = require('../Chat/ChatManager')
|
||||
|
||||
const RestoreManager = {
|
||||
async restoreFileFromV2(userId, projectId, version, pathname) {
|
||||
|
@ -158,6 +160,19 @@ const RestoreManager = {
|
|||
newRanges.comments = ranges.comments
|
||||
}
|
||||
|
||||
const newCommentThreadData =
|
||||
await ChatApiHandler.promises.generateThreadData(
|
||||
projectId,
|
||||
newRanges.comments.map(({ op: { t } }) => t)
|
||||
)
|
||||
await ChatManager.promises.injectUserInfoIntoThreads(newCommentThreadData)
|
||||
logger.debug({ newCommentThreadData }, 'emitting new comment threads')
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'new-comment-threads',
|
||||
newCommentThreadData
|
||||
)
|
||||
|
||||
return await EditorController.promises.addDocWithRanges(
|
||||
projectId,
|
||||
parentFolderId,
|
||||
|
|
|
@ -1452,6 +1452,27 @@ function useReviewPanelState(): ReviewPanel.ReviewPanelState {
|
|||
[getThread]
|
||||
)
|
||||
)
|
||||
useSocketListener(
|
||||
socket,
|
||||
'new-comment-threads',
|
||||
useCallback(
|
||||
(threads: ReviewPanelCommentThreadsApi) => {
|
||||
setCommentThreads(prevState => {
|
||||
const newThreads = { ...prevState }
|
||||
for (const threadIdString of Object.keys(threads)) {
|
||||
const threadId = threadIdString as ThreadId
|
||||
const { submitting: _, ...thread } = getThread(threadId)
|
||||
// Replace already loaded messages with the server provided ones
|
||||
thread.messages = threads[threadId].messages.map(formatComment)
|
||||
newThreads[threadId] = thread
|
||||
}
|
||||
return newThreads
|
||||
})
|
||||
handleLayoutChange({ async: true })
|
||||
},
|
||||
[getThread]
|
||||
)
|
||||
)
|
||||
|
||||
const openSubView = useRef<typeof subView>('cur_file')
|
||||
useEffect(() => {
|
||||
|
|
|
@ -19,13 +19,13 @@ const modulePath = path.join(
|
|||
__dirname,
|
||||
'../../../../app/src/Features/Chat/ChatController'
|
||||
)
|
||||
const { expect } = require('chai')
|
||||
|
||||
describe('ChatController', function () {
|
||||
beforeEach(function () {
|
||||
this.user_id = 'mock-user-id'
|
||||
this.settings = {}
|
||||
this.ChatApiHandler = {}
|
||||
this.ChatManager = {}
|
||||
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
|
||||
this.SessionManager = {
|
||||
getLoggedInUserId: sinon.stub().returns(this.user_id),
|
||||
|
@ -34,6 +34,7 @@ describe('ChatController', function () {
|
|||
requires: {
|
||||
'@overleaf/settings': this.settings,
|
||||
'./ChatApiHandler': this.ChatApiHandler,
|
||||
'./ChatManager': this.ChatManager,
|
||||
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
|
||||
'../Authentication/SessionManager': this.SessionManager,
|
||||
'../User/UserInfoManager': (this.UserInfoManager = {}),
|
||||
|
@ -106,7 +107,7 @@ describe('ChatController', function () {
|
|||
limit: (this.limit = '30'),
|
||||
before: (this.before = '12345'),
|
||||
}
|
||||
this.ChatController._injectUserInfoIntoThreads = sinon.stub().yields()
|
||||
this.ChatManager.injectUserInfoIntoThreads = sinon.stub().yields()
|
||||
this.ChatApiHandler.getGlobalMessages = sinon
|
||||
.stub()
|
||||
.yields(null, (this.messages = ['mock', 'messages']))
|
||||
|
@ -123,107 +124,4 @@ describe('ChatController', function () {
|
|||
return this.res.json.calledWith(this.messages).should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_injectUserInfoIntoThreads', function () {
|
||||
beforeEach(function () {
|
||||
this.users = {
|
||||
user_id_1: {
|
||||
mock: 'user_1',
|
||||
},
|
||||
user_id_2: {
|
||||
mock: 'user_2',
|
||||
},
|
||||
}
|
||||
this.UserInfoManager.getPersonalInfo = (userId, callback) => {
|
||||
return callback(null, this.users[userId])
|
||||
}
|
||||
sinon.spy(this.UserInfoManager, 'getPersonalInfo')
|
||||
return (this.UserInfoController.formatPersonalInfo = user => ({
|
||||
formatted: user.mock,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should inject a user object into messaged and resolved data', function (done) {
|
||||
return this.ChatController._injectUserInfoIntoThreads(
|
||||
{
|
||||
thread1: {
|
||||
resolved: true,
|
||||
resolved_by_user_id: 'user_id_1',
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'foo',
|
||||
},
|
||||
{
|
||||
user_id: 'user_id_2',
|
||||
content: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
thread2: {
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'baz',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
(error, threads) => {
|
||||
expect(threads).to.deep.equal({
|
||||
thread1: {
|
||||
resolved: true,
|
||||
resolved_by_user_id: 'user_id_1',
|
||||
resolved_by_user: { formatted: 'user_1' },
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
user: { formatted: 'user_1' },
|
||||
content: 'foo',
|
||||
},
|
||||
{
|
||||
user_id: 'user_id_2',
|
||||
user: { formatted: 'user_2' },
|
||||
content: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
thread2: {
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
user: { formatted: 'user_1' },
|
||||
content: 'baz',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should only need to look up each user once', function (done) {
|
||||
return this.ChatController._injectUserInfoIntoThreads(
|
||||
[
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'foo',
|
||||
},
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
(error, threads) => {
|
||||
this.UserInfoManager.getPersonalInfo.calledOnce.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
135
services/web/test/unit/src/Chat/ChatManagerTests.js
Normal file
135
services/web/test/unit/src/Chat/ChatManagerTests.js
Normal file
|
@ -0,0 +1,135 @@
|
|||
const SandboxedModule = require('sandboxed-module')
|
||||
const path = require('path')
|
||||
const sinon = require('sinon')
|
||||
const modulePath = path.join(
|
||||
__dirname,
|
||||
'../../../../app/src/Features/Chat/ChatManager'
|
||||
)
|
||||
const { expect } = require('chai')
|
||||
|
||||
describe('ChatManager', function () {
|
||||
beforeEach(function () {
|
||||
this.user_id = 'mock-user-id'
|
||||
this.ChatManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'../User/UserInfoManager': (this.UserInfoManager = {}),
|
||||
'../User/UserInfoController': (this.UserInfoController = {}),
|
||||
},
|
||||
})
|
||||
this.req = {
|
||||
params: {
|
||||
project_id: this.project_id,
|
||||
},
|
||||
}
|
||||
this.res = {
|
||||
json: sinon.stub(),
|
||||
send: sinon.stub(),
|
||||
sendStatus: sinon.stub(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('injectUserInfoIntoThreads', function () {
|
||||
beforeEach(function () {
|
||||
this.users = {
|
||||
user_id_1: {
|
||||
mock: 'user_1',
|
||||
},
|
||||
user_id_2: {
|
||||
mock: 'user_2',
|
||||
},
|
||||
}
|
||||
this.UserInfoManager.getPersonalInfo = (userId, callback) => {
|
||||
return callback(null, this.users[userId])
|
||||
}
|
||||
sinon.spy(this.UserInfoManager, 'getPersonalInfo')
|
||||
return (this.UserInfoController.formatPersonalInfo = user => ({
|
||||
formatted: user.mock,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should inject a user object into messaged and resolved data', function (done) {
|
||||
return this.ChatManager.injectUserInfoIntoThreads(
|
||||
{
|
||||
thread1: {
|
||||
resolved: true,
|
||||
resolved_by_user_id: 'user_id_1',
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'foo',
|
||||
},
|
||||
{
|
||||
user_id: 'user_id_2',
|
||||
content: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
thread2: {
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'baz',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
(error, threads) => {
|
||||
expect(error).to.be.null
|
||||
expect(threads).to.deep.equal({
|
||||
thread1: {
|
||||
resolved: true,
|
||||
resolved_by_user_id: 'user_id_1',
|
||||
resolved_by_user: { formatted: 'user_1' },
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
user: { formatted: 'user_1' },
|
||||
content: 'foo',
|
||||
},
|
||||
{
|
||||
user_id: 'user_id_2',
|
||||
user: { formatted: 'user_2' },
|
||||
content: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
thread2: {
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
user: { formatted: 'user_1' },
|
||||
content: 'baz',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should only need to look up each user once', function (done) {
|
||||
return this.ChatManager.injectUserInfoIntoThreads(
|
||||
[
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'foo',
|
||||
},
|
||||
{
|
||||
user_id: 'user_id_1',
|
||||
content: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
(error, threads) => {
|
||||
expect(error).to.be.null
|
||||
this.UserInfoManager.getPersonalInfo.calledOnce.should.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -31,6 +31,9 @@ describe('RestoreManager', function () {
|
|||
promises: {},
|
||||
}),
|
||||
'../Chat/ChatApiHandler': (this.ChatApiHandler = { promises: {} }),
|
||||
'../Chat/ChatManager': (this.ChatManager = { promises: {} }),
|
||||
'../Editor/EditorRealTimeController': (this.EditorRealTimeController =
|
||||
{}),
|
||||
},
|
||||
})
|
||||
this.user_id = 'mock-user-id'
|
||||
|
@ -324,6 +327,13 @@ describe('RestoreManager', function () {
|
|||
},
|
||||
},
|
||||
})
|
||||
this.ChatApiHandler.promises.generateThreadData = sinon
|
||||
.stub()
|
||||
.resolves({})
|
||||
this.ChatManager.promises.injectUserInfoIntoThreads = sinon
|
||||
.stub()
|
||||
.resolves()
|
||||
this.EditorRealTimeController.emitToRoom = sinon.stub()
|
||||
this.tracked_changes = [
|
||||
{
|
||||
op: { pos: 4, i: 'bar' },
|
||||
|
|
Loading…
Reference in a new issue