From 07917d0e51e1469e2fad310ce0cee1324f72f0f1 Mon Sep 17 00:00:00 2001 From: cballevre Date: Wed, 13 Nov 2024 18:08:31 +0100 Subject: [PATCH] feat: Open note inside public repository The opening of the notes is subtle. The note is always opened in the instance of the note creator. This is especially true for shared notes or notes in shared folders. In this case, you need to contact the cozy-stack to find out where to open it. As this can take a little time, we use a redirection page rather than pre-calculating all the links when a folder is displayed --- src/declarations.d.ts | 8 ++ src/locales/en.json | 6 ++ src/locales/fr.json | 6 ++ src/modules/navigation/AppRoute.jsx | 3 + src/modules/navigation/PublicNoteRedirect.tsx | 68 ++++++++++++++++ src/modules/navigation/hooks/helpers.spec.js | 80 ++++++++++++++++++- src/modules/navigation/hooks/helpers.ts | 38 +++++++-- src/modules/navigation/hooks/useFileLink.tsx | 17 +++- .../views/Public/PublicNoteRedirectView.tsx | 73 +++++++++++++++++ src/targets/public/components/AppRouter.jsx | 3 +- 10 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 src/modules/navigation/PublicNoteRedirect.tsx create mode 100644 src/modules/views/Public/PublicNoteRedirectView.tsx diff --git a/src/declarations.d.ts b/src/declarations.d.ts index d9e98b7492..36e56c638d 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -91,6 +91,14 @@ declare module 'cozy-client/dist/models/file' { ) => boolean } +declare module 'cozy-client/dist/models/note' { + export const fetchURL: ( + client: import('cozy-client/types/CozyClient').CozyClient, + file: { id: string }, + options: { pathname: string } + ) => Promise +} + declare module '*.svg' { import { FC, SVGProps } from 'react' const content: FC> diff --git a/src/locales/en.json b/src/locales/en.json index b7b0d0d6ab..1cef520d6b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -848,5 +848,11 @@ }, "LastUpdate": { "titleFormat": "MMMM DD, YYYY, HH:MM" + }, + "PublicNoteRedirect": { + "error": { + "title": "Unable to access document", + "subtitle": "The share link appears to be missing or invalid. Please ask the document owner to check access" + } } } diff --git a/src/locales/fr.json b/src/locales/fr.json index d5ca370349..d00cf144ab 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -849,5 +849,11 @@ }, "LastUpdate": { "titleFormat": "DD MMMM YYYY, HH:MM" + }, + "PublicNoteRedirect": { + "error": { + "title": "Impossible d'accéder au document", + "subtitle": "Le lien de partage semble manquant ou invalide. Merci de demander au propriétaire du document de vérifier les accès" + } } } diff --git a/src/modules/navigation/AppRoute.jsx b/src/modules/navigation/AppRoute.jsx index 36e868da98..12cf67989c 100644 --- a/src/modules/navigation/AppRoute.jsx +++ b/src/modules/navigation/AppRoute.jsx @@ -23,6 +23,7 @@ import { ROOT_DIR_ID, TRASH_DIR_ID } from 'constants/config' import { SentryRoutes } from 'lib/sentry' import { UploaderComponent } from 'modules//views/Upload/UploaderComponent' import Layout from 'modules/layout/Layout' +import { PublicNoteRedirect } from 'modules/navigation/PublicNoteRedirect' import FileOpenerExternal from 'modules/viewer/FileOpenerExternal' import HarvestRoutes from 'modules/views/Drive/HarvestRoutes' import { SharedDrivesFolderView } from 'modules/views/Drive/SharedDrivesFolderView' @@ -50,6 +51,8 @@ const FilesRedirect = () => { const AppRoute = () => ( } /> + } /> + }> } /> } /> diff --git a/src/modules/navigation/PublicNoteRedirect.tsx b/src/modules/navigation/PublicNoteRedirect.tsx new file mode 100644 index 0000000000..fb2e658903 --- /dev/null +++ b/src/modules/navigation/PublicNoteRedirect.tsx @@ -0,0 +1,68 @@ +import React, { FC, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { useClient } from 'cozy-client' +import { fetchURL } from 'cozy-client/dist/models/note' +import Empty from 'cozy-ui/transpiled/react/Empty' +import Icon from 'cozy-ui/transpiled/react/Icon' +import SadCozyIcon from 'cozy-ui/transpiled/react/Icons/SadCozy' +import Spinner from 'cozy-ui/transpiled/react/Spinner' +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' + +import { joinPath } from 'lib/path' +import { DummyLayout } from 'modules/layout/DummyLayout' + +const PublicNoteRedirect: FC = () => { + const { t } = useI18n() + const { fileId } = useParams() + const client = useClient() + + const [noteUrl, setNoteUrl] = useState(null) + const [fetchStatus, setFetchStatus] = useState< + 'failed' | 'loading' | 'pending' | 'loaded' + >('pending') + + useEffect(() => { + const fetchNoteUrl = async (fileId: string): Promise => { + setFetchStatus('loading') + try { + const url = await fetchURL( + client, + { + id: fileId + }, + { + pathname: joinPath(location.pathname, '') + } + ) + setNoteUrl(url) + setFetchStatus('loaded') + } catch (error) { + setFetchStatus('failed') + } + } + + if (fileId) { + void fetchNoteUrl(fileId) + } + }, [fileId, client]) + + if (noteUrl) { + window.location.href = noteUrl + } + + return ( + + {fetchStatus === 'failed' && ( + } + title={t('PublicNoteRedirect.error.title')} + text={t('PublicNoteRedirect.error.subtitle')} + /> + )} + {fetchStatus !== 'failed' && } + + ) +} + +export { PublicNoteRedirect } diff --git a/src/modules/navigation/hooks/helpers.spec.js b/src/modules/navigation/hooks/helpers.spec.js index 193593807b..b082d07548 100644 --- a/src/modules/navigation/hooks/helpers.spec.js +++ b/src/modules/navigation/hooks/helpers.spec.js @@ -32,7 +32,7 @@ describe('computeFileType', () => { expect(computeFileType(file)).toBe('nextcloud-file') }) - it('should return "note" for notes', () => { + it('should return "public-note-same-instance" for public notes on the same instance', () => { const file = { _type: 'io.cozy.files', name: 'My journal.cozy-note', @@ -40,9 +40,68 @@ describe('computeFileType', () => { metadata: { title: '', version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' + } + } + expect( + computeFileType(file, { isPublic: true, cozyUrl: 'https://example.com' }) + ).toBe('public-note-same-instance') + }) + + it('should return "note" for notes on the same instance', () => { + const file = { + _type: 'io.cozy.files', + name: 'My journal.cozy-note', + type: 'file', + metadata: { + title: '', + version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' } } - expect(computeFileType(file)).toBe('note') + expect(computeFileType(file, { cozyUrl: 'https://example.com/' })).toBe( + 'note' + ) + }) + + it('should return "public-note" for notes on an another instance', () => { + const file = { + _type: 'io.cozy.files', + name: 'My journal.cozy-note', + type: 'file', + metadata: { + title: '', + version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' + } + } + expect(computeFileType(file, { cozyUrl: 'https://another.com/' })).toBe( + 'public-note' + ) + }) + + it('should return "public-note" for public notes', () => { + const file = { + _type: 'io.cozy.files', + name: 'My journal.cozy-note', + type: 'file', + metadata: { + title: '', + version: '0' + }, + cozyMetadata: { + createdOn: 'https://example.com/' + } + } + expect( + computeFileType(file, { isPublic: true, cozyUrl: 'https://another.com' }) + ).toBe('public-note') }) it('should return "onlyoffice" for files opened by OnlyOffice when Office is enabled', () => { @@ -158,6 +217,23 @@ describe('computePath', () => { ) }) + it('should return correct path for public-note', () => { + const file = { _id: 'note123' } + expect( + computePath(file, { type: 'public-note', pathname: '/public' }) + ).toBe('/note/note123') + }) + + it('should return correct path for public-note-same-instance', () => { + const file = { _id: 'note123' } + expect( + computePath(file, { + type: 'public-note-same-instance', + pathname: '/public' + }) + ).toBe('/?id=note123') + }) + it('should return correct path for shortcut', () => { const file = { _id: 'shortcut123' } expect(computePath(file, { type: 'shortcut', pathname: '/any' })).toBe( diff --git a/src/modules/navigation/hooks/helpers.ts b/src/modules/navigation/hooks/helpers.ts index deca0eaedd..c3f25560a7 100644 --- a/src/modules/navigation/hooks/helpers.ts +++ b/src/modules/navigation/hooks/helpers.ts @@ -7,11 +7,14 @@ import { import type { File } from 'components/FolderPicker/types' import { TRASH_DIR_ID } from 'constants/config' +import { joinPath } from 'lib/path' import { isNextcloudShortcut } from 'modules/nextcloud/helpers' import { makeOnlyOfficeFileRoute } from 'modules/views/OnlyOffice/helpers' interface ComputeFileTypeOptions { isOfficeEnabled?: boolean + isPublic?: boolean + cozyUrl?: string } interface ComputePathOptions { @@ -22,7 +25,11 @@ interface ComputePathOptions { export const computeFileType = ( file: File, - { isOfficeEnabled = false }: ComputeFileTypeOptions = {} + { + isOfficeEnabled = false, + isPublic = false, + cozyUrl = '' + }: ComputeFileTypeOptions = {} ): string => { if (file._id === TRASH_DIR_ID) { return 'trash' @@ -31,7 +38,16 @@ export const computeFileType = ( } else if (file._type === 'io.cozy.remote.nextcloud.files') { return isDirectory(file) ? 'nextcloud-directory' : 'nextcloud-file' } else if (isNote(file)) { - return 'note' + // createdOn url ends with a trailing slash whereas cozyUrl does not joinPath fixes this + const isSameInstance = + joinPath(cozyUrl, '') === file.cozyMetadata?.createdOn + if (isPublic && isSameInstance) { + return 'public-note-same-instance' + } else if (isSameInstance) { + return 'note' + } else { + return 'public-note' + } } else if (shouldBeOpenedByOnlyOffice(file) && isOfficeEnabled) { return 'onlyoffice' } else if (isNextcloudShortcut(file)) { @@ -46,13 +62,15 @@ export const computeFileType = ( } export const computeApp = (type: string): string => { - if (type === 'nextcloud-file') { - return 'nextcloud' - } - if (type === 'note') { - return 'notes' + switch (type) { + case 'nextcloud-file': + return 'nextcloud' + case 'note': + case 'public-note-same-instance': + return 'notes' + default: + return 'drive' } - return 'drive' } export const computePath = ( @@ -75,6 +93,10 @@ export const computePath = ( return file.links?.self ?? '' case 'note': return `/n/${file._id}` + case 'public-note-same-instance': + return `/?id=${file._id}` + case 'public-note': + return `/note/${file._id}` case 'shortcut': return `/external/${file._id}` case 'directory': diff --git a/src/modules/navigation/hooks/useFileLink.tsx b/src/modules/navigation/hooks/useFileLink.tsx index 03e73451f0..1c09b9421d 100644 --- a/src/modules/navigation/hooks/useFileLink.tsx +++ b/src/modules/navigation/hooks/useFileLink.tsx @@ -6,6 +6,7 @@ import { useClient, generateWebLink } from 'cozy-client' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import type { File } from 'components/FolderPicker/types' +import { joinPath } from 'lib/path' import { computeFileType, computeApp, @@ -48,8 +49,13 @@ const useFileLink = (file: File): UseFileLinkResult => { const isOfficeEnabled = computeOfficeEnabled(isDesktop) const { isPublic } = usePublicContext() + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const cozyUrl = client?.getStackClient().uri as string + const type = computeFileType(file, { - isOfficeEnabled + isOfficeEnabled, + isPublic, + cozyUrl }) const app = computeApp(type) const path = computePath(file, { @@ -81,11 +87,14 @@ const useFileLink = (file: File): UseFileLinkResult => { ? path : generateWebLink({ slug: app, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment - cozyUrl: client?.getStackClient().uri, + cozyUrl, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment subDomainType: client?.getInstanceOptions().subdomain, - pathname: currentURL.pathname, + // Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error + pathname: + type === 'public-note-same-instance' + ? joinPath(currentURL.pathname, '') + : currentURL.pathname, searchParams: searchParams as unknown as unknown[], hash: to.pathname }) diff --git a/src/modules/views/Public/PublicNoteRedirectView.tsx b/src/modules/views/Public/PublicNoteRedirectView.tsx new file mode 100644 index 0000000000..fac84e2662 --- /dev/null +++ b/src/modules/views/Public/PublicNoteRedirectView.tsx @@ -0,0 +1,73 @@ +import React, { FC, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' + +import { useClient } from 'cozy-client' +import { fetchURL } from 'cozy-client/dist/models/note' +import Empty from 'cozy-ui/transpiled/react/Empty' +import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite' +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' + +import EmptyIcon from 'assets/icons/icon-folder-broken.svg' +import { joinPath } from 'lib/path' + +const PublicNoteRedirectView: FC = () => { + const { t } = useI18n() + const { fileId } = useParams() + const client = useClient() + + const [noteUrl, setNoteUrl] = useState(null) + const [fetchStatus, setFetchStatus] = useState< + 'failed' | 'loading' | 'pending' | 'loaded' + >('pending') + + useEffect(() => { + const fetchNoteUrl = async (fileId: string): Promise => { + setFetchStatus('loading') + try { + const url = await fetchURL( + client, + { + id: fileId + }, + { + pathname: joinPath(location.pathname, '') + } + ) + setNoteUrl(url) + setFetchStatus('loaded') + } catch (error) { + setFetchStatus('failed') + } + } + + if (fileId) { + void fetchNoteUrl(fileId) + } + }, [fileId, client]) + + if (noteUrl) { + window.location.href = noteUrl + } + + return ( + <> + + {fetchStatus === 'failed' && ( + + )} + {fetchStatus !== 'failed' && ( + + )} + + ) +} + +export { PublicNoteRedirectView } diff --git a/src/targets/public/components/AppRouter.jsx b/src/targets/public/components/AppRouter.jsx index 531cd0ec1d..41f1fae5e9 100644 --- a/src/targets/public/components/AppRouter.jsx +++ b/src/targets/public/components/AppRouter.jsx @@ -7,6 +7,7 @@ import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import FileHistory from 'components/FileHistory' import { SentryRoutes } from 'lib/sentry' import ExternalRedirect from 'modules/navigation/ExternalRedirect' +import { PublicNoteRedirect } from 'modules/navigation/PublicNoteRedirect' import LightFileViewer from 'modules/public/LightFileViewer' import PublicLayout from 'modules/public/PublicLayout' import OnlyOfficeView from 'modules/views/OnlyOffice' @@ -91,7 +92,7 @@ const AppRouter = ({ element={} /> - + } /> } />