diff --git a/front/src/components/Articles.jsx b/front/src/components/Articles.jsx index c9dc6f5f2..d6c8acdbb 100644 --- a/front/src/components/Articles.jsx +++ b/front/src/components/Articles.jsx @@ -1,5 +1,5 @@ import { Loading, Modal as GeistModal, useModal, Button as GeistButton } from '@geist-ui/core' -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { shallowEqual, useSelector } from 'react-redux' import { CurrentUserContext } from '../contexts/CurrentUser' @@ -21,6 +21,7 @@ import TagsList from './tag/TagsList.jsx' export default function Articles () { const { t } = useTranslation() + const backendEndpoint = useSelector(state => state.applicationConfig.backendEndpoint) const currentUser = useSelector(state => state.activeUser, shallowEqual) const selectedTagIds = useSelector((state) => state.activeUser.selectedTagIds || []) const { @@ -90,12 +91,55 @@ export default function Articles () { } }, [articles]) + const handleStateUpdated = useCallback((event) => { + const parsedData = JSON.parse(event.data) + if (parsedData.articleStateUpdated) { + const articleStateUpdated = parsedData.articleStateUpdated + const updatedArticles = articles.map((article) => { + if (article._id === articleStateUpdated._id) { + return { + ...article, + soloSession: articleStateUpdated.soloSession, + collaborativeSession: articleStateUpdated.collaborativeSession, + } + } + return article + }) + if (activeWorkspaceId) { + mutate({ + workspace: { + ...data.workspace, + articles: updatedArticles + } + }, { revalidate: false }) + } else { + mutate({ + articles: updatedArticles + }, { revalidate: false }) + } + } + }, [articles]) + + useEffect(() => { + let events + if (!isLoading) { + events = new EventSource(`${backendEndpoint}/events`) + events.onmessage = (event) => { + handleStateUpdated(event) + } + } + return () => { + if (events) { + events.close() + } + } + }, [isLoading, handleStateUpdated]) + const keepArticles = useMemo(() => articles .filter((article) => { if (selectedTagIds.length === 0) { return true } - // if we find at least one matching tag in the selected list, we keep the article return selectedTagIds.some((tagId) => article.tags.find(({ _id }) => _id === tagId)) }) diff --git a/front/src/components/solo/SoloSessionAction.jsx b/front/src/components/solo/SoloSessionAction.jsx index 85e144a4e..9c562fe18 100644 --- a/front/src/components/solo/SoloSessionAction.jsx +++ b/front/src/components/solo/SoloSessionAction.jsx @@ -36,7 +36,7 @@ export default function SoloSessionAction ({ collaborativeSession, soloSession, } } } - }, [collaborativeSession]) + }, [soloSession]) if (collaborativeSession) { return <> diff --git a/front/src/components/tag/TagsList.jsx b/front/src/components/tag/TagsList.jsx index 809e952b0..833509611 100644 --- a/front/src/components/tag/TagsList.jsx +++ b/front/src/components/tag/TagsList.jsx @@ -42,6 +42,7 @@ export default function TagsList () { name={`filterTag-${t._id}`} onClick={handleTagSelected} disableAction={false} + selected={selectedTagIds.includes(t._id)} /> ))} diff --git a/front/vite.config.js b/front/vite.config.js index 606376283..dfa180923 100644 --- a/front/vite.config.js +++ b/front/vite.config.js @@ -57,7 +57,7 @@ export default defineConfig(async ({ mode }) => { prependPath: false }, // as in infrastructure/files/stylo.huma-num.fr.conf - '^/(login/openid|login/local|login/zotero|logout|authorization-code)': { + '^/(login/openid|login/local|login/zotero|logout|authorization-code|events)': { target: 'http://127.0.0.1:3030' } } diff --git a/graphql/app.js b/graphql/app.js index 2631e6d4f..7cd560e79 100644 --- a/graphql/app.js +++ b/graphql/app.js @@ -14,7 +14,7 @@ const MongoStore = require('connect-mongo')(session) const passport = require('passport') const OidcStrategy = require('passport-openidconnect').Strategy const LocalStrategy = require('passport-local').Strategy -const OAuthStrategy = require('passport-oauth').OAuthStrategy; +const OAuthStrategy = require('passport-oauth').OAuthStrategy const { logger } = require('./logger') const pino = require('pino-http')({ logger @@ -30,6 +30,7 @@ const { createTagLoader, createUserLoader, createArticleLoader, createVersionLoa const { setupWSConnection } = require('y-websocket/bin/utils') const WebSocket = require('ws') +const { handleEvents } = require('./events') const wss = new WebSocket.Server({ noServer: true }) const app = express() @@ -184,6 +185,8 @@ app.get('/version', (req, res) => res.json({ version: pkg.version })) +app.get('/events', handleEvents) + app.get('/login/openid', async (req, res, next) => { if (req.user) { const { email } = req.user @@ -251,7 +254,7 @@ app.use('/authorization-code/callback', }) app.get('/logout', (req, res, next) => { - req.logout(function(err) { + req.logout(function (err) { if (err) { return next(err) } @@ -277,7 +280,7 @@ app.post('/login/local', } ) -function createLoaders() { +function createLoaders () { return { tags: createTagLoader(), users: createUserLoader(), @@ -319,7 +322,7 @@ mongoose logger.info('Listening on http://localhost:%s', listenPort) const server = app.listen(listenPort) server.on('upgrade', (request, socket, head) => { - wss.handleUpgrade(request, socket, head, function handleAuth(ws) { + wss.handleUpgrade(request, socket, head, function handleAuth (ws) { // const jwtToken = new URL('http://localhost' + request.url).searchParams.get("token") // TODO: check token and permissions wss.emit('connection', ws, request) diff --git a/graphql/events.js b/graphql/events.js new file mode 100644 index 000000000..b16a728a4 --- /dev/null +++ b/graphql/events.js @@ -0,0 +1,38 @@ +let clients = [] + +const handleEvents = (request, response) => { + const headers = { + 'Content-Type': 'text/event-stream', + 'Connection': 'keep-alive', + 'Cache-Control': 'no-cache' + } + response.writeHead(200, headers) + const clientId = Date.now() + const newClient = { + id: clientId, + response + } + clients.push(newClient) + request.on('close', () => { + console.log(`${clientId} Connection closed`) + clients = clients.filter(client => client.id !== clientId) + }) +} + +function notifyArticleStatusChange (article) { + clients.forEach(client => { + client.response.write(`data: ${JSON.stringify({ + articleStateUpdated: { + collaborativeSession: article.collaborativeSession, + soloSession: article.soloSession, + title: article.title, + _id: article._id + } + })}\n\n`) + }) +} + +module.exports = { + handleEvents, + notifyArticleStatusChange +} diff --git a/graphql/resolvers/articleResolver.js b/graphql/resolvers/articleResolver.js index 8b53f4333..78152f6ed 100644 --- a/graphql/resolvers/articleResolver.js +++ b/graphql/resolvers/articleResolver.js @@ -13,6 +13,7 @@ const { ApiError } = require('../helpers/errors') const { reformat } = require('../helpers/metadata.js') const { computeMajorVersion, computeMinorVersion } = require('../helpers/versions') const { previewEntries } = require('../helpers/bibliography') +const { notifyArticleStatusChange } = require('../events') async function getUser (userId) { @@ -405,6 +406,7 @@ module.exports = { } article.soloSession = soloSession await article.save() + notifyArticleStatusChange(article) return soloSession }, @@ -415,6 +417,7 @@ module.exports = { } article.soloSession = null await article.save() + notifyArticleStatusChange(article) } // no solo session to stop (ignore) return article