From 95bb9e4ef2a097034fba88ea3525b80d8d3f283c Mon Sep 17 00:00:00 2001 From: cballevre Date: Wed, 21 Aug 2024 17:38:05 +0200 Subject: [PATCH 1/2] feat: Update cozy-flags from 3.2.2 to 4.0.0 --- package.json | 2 +- src/cozy-flags.d.ts | 15 --------------- yarn.lock | 8 ++++---- 3 files changed, 5 insertions(+), 20 deletions(-) delete mode 100644 src/cozy-flags.d.ts diff --git a/package.json b/package.json index 0bbc90012c..ff4a47c033 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "cozy-client": "^48.17.0", "cozy-device-helper": "2.7.0", "cozy-doctypes": "1.83.8", - "cozy-flags": "3.2.2", + "cozy-flags": "4.0.0", "cozy-harvest-lib": "^29.1.0", "cozy-intent": "^2.19.2", "cozy-interapp": "^0.9.0", diff --git a/src/cozy-flags.d.ts b/src/cozy-flags.d.ts deleted file mode 100644 index b7b29f39e8..0000000000 --- a/src/cozy-flags.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare module 'cozy-flags' { - export const FlagSwitcher: React.FC - export const FlagContext: React.Context - export const FlagProvider: React.FC - export const Flag: React.FC - export const useFlags: () => Record - export const useFlag: (flagName: string) => boolean - export const withFlags: (Component: React.FC) => React.FC - export const withFlag: (flagName: string) => (Component: React.FC) => React.FC - export const useFlagSwitcher: () => { - flags: Record - setFlag: (flagName: string, value: boolean) => void - } - export default useFlag -} diff --git a/yarn.lock b/yarn.lock index 1edd239d0f..804acd38a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6062,10 +6062,10 @@ cozy-doctypes@^1.91.1: lodash "^4.17.19" prop-types "^15.7.2" -cozy-flags@3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/cozy-flags/-/cozy-flags-3.2.2.tgz#778b148ec6db0d9d9988fee43952e4b21547fb71" - integrity sha512-IWp/naaY+F9baSkLOjzi4M2UYHdIYAAiUBYGRempu3UbFQ1muCeclsut0OBy4oD8f8ENG4HhDQAdwSBAULOoFw== +cozy-flags@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cozy-flags/-/cozy-flags-4.0.0.tgz#047f9a93f826808bfd23ab206d0bf8235c162237" + integrity sha512-us+XDoqPCnDflu7gJrZAmE5SDmkIx2QEhs+fItQOf2JjnGpfBvmIhLMv51/P5INPL4bAQSUHE6ddwNFwOruqBw== dependencies: microee "^0.0.6" From 8774b577d45042f2d2f826cbbb679a188c0e54c8 Mon Sep 17 00:00:00 2001 From: cballevre Date: Fri, 30 Aug 2024 17:17:45 +0200 Subject: [PATCH 2/2] feat: Show announcements --- manifest.webapp | 10 ++ .../Announcements/Announcements.tsx | 44 ++++++++ .../Announcements/AnnouncementsDialog.tsx | 101 +++++++++++++++++ .../AnnouncementsDialogContent.tsx | 105 ++++++++++++++++++ src/components/Announcements/helpers.ts | 19 ++++ src/components/Announcements/types.ts | 46 ++++++++ src/containers/App.jsx | 2 + src/cozy-ui.d.ts | 23 +++- src/global.d.ts | 2 + src/hooks/useAnnouncements.tsx | 93 ++++++++++++++++ src/hooks/useAnnouncementsImage.tsx | 43 +++++++ src/hooks/useAnnouncementsSettings.tsx | 53 +++++++++ src/locales/en.json | 5 + src/locales/fr.json | 7 +- 14 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 src/components/Announcements/Announcements.tsx create mode 100644 src/components/Announcements/AnnouncementsDialog.tsx create mode 100644 src/components/Announcements/AnnouncementsDialogContent.tsx create mode 100644 src/components/Announcements/helpers.ts create mode 100644 src/components/Announcements/types.ts create mode 100644 src/hooks/useAnnouncements.tsx create mode 100644 src/hooks/useAnnouncementsImage.tsx create mode 100644 src/hooks/useAnnouncementsSettings.tsx diff --git a/manifest.webapp b/manifest.webapp index 49861d3204..df17f30be2 100644 --- a/manifest.webapp +++ b/manifest.webapp @@ -98,6 +98,16 @@ "identities": { "description": "Required to display identities debug data", "type": "io.cozy.identities" + }, + "announcements-dev": { + "type": "cc.cozycloud.announcements.dev", + "verbs": ["GET"], + "description": "Remote-doctype required to get announcements, for development purposes" + }, + "announcements-uploads-dev": { + "type": "cc.cozycloud.announcements.dev.uploads", + "verbs": ["GET"], + "description": "Remote-doctype required to get announcements images, for development purposes" } }, "routes": { diff --git a/src/components/Announcements/Announcements.tsx b/src/components/Announcements/Announcements.tsx new file mode 100644 index 0000000000..6f1a5baaec --- /dev/null +++ b/src/components/Announcements/Announcements.tsx @@ -0,0 +1,44 @@ +import React, { FC, useState } from 'react' +import { differenceInHours } from 'date-fns' + +import flag from 'cozy-flags' + +import { AnnouncementsDialog } from './AnnouncementsDialog' +import { useAnnouncements } from 'hooks/useAnnouncements' +import { AnnouncementsConfigFlag } from './types' +import { useAnnouncementsSettings } from 'hooks/useAnnouncementsSettings' + +const Announcements: FC = () => { + const config = flag('home.announcements') + const [hasBeenDismissed, setBeenDismissed] = useState(false) + const { values, save } = useAnnouncementsSettings() + + const handleDismiss = (): void => { + save({ + dismissedAt: new Date().toISOString() + }) + setBeenDismissed(true) + } + + const moreThan = config?.delayAfterDismiss ?? 24 + const hasBeenDismissedForMoreThan = values.dismissedAt + ? differenceInHours(Date.parse(values.dismissedAt), new Date()) >= moreThan + : true + const canBeDisplayed = !hasBeenDismissed && hasBeenDismissedForMoreThan + const announcements = useAnnouncements({ + canBeDisplayed + }) + + if (canBeDisplayed && announcements.length > 0) { + return ( + + ) + } + + return null +} + +export { Announcements } diff --git a/src/components/Announcements/AnnouncementsDialog.tsx b/src/components/Announcements/AnnouncementsDialog.tsx new file mode 100644 index 0000000000..36e7f4d3c7 --- /dev/null +++ b/src/components/Announcements/AnnouncementsDialog.tsx @@ -0,0 +1,101 @@ +import React, { useState, FC } from 'react' +import SwipeableViews from 'react-swipeable-views' + +import { FixedActionsDialog } from 'cozy-ui/transpiled/react/CozyDialogs' +import CozyTheme from 'cozy-ui/transpiled/react/providers/CozyTheme' +import MobileStepper from 'cozy-ui/transpiled/react/MobileStepper' +import IconButton from 'cozy-ui/transpiled/react/IconButton' +import Icon from 'cozy-ui/transpiled/react/Icon' +import LeftIcon from 'cozy-ui/transpiled/react/Icons/Left' +import RightIcon from 'cozy-ui/transpiled/react/Icons/Right' +import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' + +import { AnnouncementsDialogContent } from './AnnouncementsDialogContent' +import { Announcement } from './types' +import { useAnnouncementsSettings } from 'hooks/useAnnouncementsSettings' + +interface AnnouncementsDialogProps { + announcements: Array + onDismiss: () => void +} + +const AnnouncementsDialog: FC = ({ + announcements, + onDismiss +}) => { + const { values, save } = useAnnouncementsSettings() + const { isMobile } = useBreakpoints() + + const [activeStep, setActiveStep] = useState(0) + + const handleBack = (): void => { + setActiveStep(activeStep - 1) + } + + const handleNext = (): void => { + const uuid = announcements[activeStep].attributes.uuid + if (!values?.seen.includes(uuid)) { + save({ + seen: [...(values?.seen ?? []), uuid] + }) + } + setActiveStep(activeStep + 1) + } + + const handleChangedIndex = (index: number): void => { + setActiveStep(index) + } + + const maxSteps = announcements.length + + return ( + + + {announcements.map((announcement, index) => ( + + ))} + + } + actions={ + maxSteps > 1 ? ( + + + + } + backButton={ + + + + } + /> + ) : null + } + /> + + ) +} + +export { AnnouncementsDialog } diff --git a/src/components/Announcements/AnnouncementsDialogContent.tsx b/src/components/Announcements/AnnouncementsDialogContent.tsx new file mode 100644 index 0000000000..28536d6b43 --- /dev/null +++ b/src/components/Announcements/AnnouncementsDialogContent.tsx @@ -0,0 +1,105 @@ +import React, { FC } from 'react' +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' +import Typography from 'cozy-ui/transpiled/react/Typography' +import Buttons from 'cozy-ui/transpiled/react/Buttons' + +import { Announcement } from './types' +import { useAnnouncementsImage } from 'hooks/useAnnouncementsImage' + +interface AnnouncementsDialogContentProps { + isLast: boolean + announcement: Announcement + onDismiss: () => void + onNext: () => void +} + +const AnnouncementsDialogContent: FC = ({ + isLast, + announcement, + onDismiss, + onNext +}) => { + const { t, f } = useI18n() + const primaryImage = useAnnouncementsImage( + announcement.attributes.primary_image.data.attributes.formats.small.url + ) + const secondaryImage = useAnnouncementsImage( + announcement.attributes.secondary_image.data?.attributes.formats.thumbnail + .url + ) + + const handleMainAction = (): void => { + if (announcement.attributes.main_action?.link) { + window.open(announcement.attributes.main_action.link, '_blank') + } + } + + return ( +
+ {primaryImage ? ( + { + ) : null} + + {announcement.attributes.title} + + + {f( + announcement.attributes.start_at, + t('AnnouncementsDialogContent.dateFormat') + )} + + + {announcement.attributes.content} + + {announcement.attributes.main_action ? ( + + ) : null} + + {secondaryImage ? ( + { + ) : null} +
+ ) +} + +export { AnnouncementsDialogContent } diff --git a/src/components/Announcements/helpers.ts b/src/components/Announcements/helpers.ts new file mode 100644 index 0000000000..4280c043e3 --- /dev/null +++ b/src/components/Announcements/helpers.ts @@ -0,0 +1,19 @@ +import { Announcement } from './types' + +export const getUnseenAnnouncements = ( + data: Announcement[], + announcements_seen: string[] +): Announcement[] => { + return data.filter(announcement => { + if (announcements_seen) { + return !announcements_seen.includes(announcement.attributes.uuid) + } + return true + }) +} + +export const isAnnouncement = ( + announcement: unknown +): announcement is Announcement => { + return (announcement as Announcement).attributes?.title !== undefined +} diff --git a/src/components/Announcements/types.ts b/src/components/Announcements/types.ts new file mode 100644 index 0000000000..3d9b38f80a --- /dev/null +++ b/src/components/Announcements/types.ts @@ -0,0 +1,46 @@ +export interface Announcement { + id: string + type: string + attributes: { + title: string + content: string + start_at: string + uuid: string + main_action?: { + label: string + link: string + } + primary_image: { + data: { + attributes: { + formats: { + small: { + url: string + } + } + alternativeText?: string + } + } + } + secondary_image: { + data: { + attributes: { + formats: { + thumbnail: { + url: string + } + } + alternativeText?: string + } + } | null + } + } +} + +export interface AnnouncementsConfig { + remoteDoctype: string + channels: string + delayAfterDismiss: number +} + +export type AnnouncementsConfigFlag = AnnouncementsConfig | null diff --git a/src/containers/App.jsx b/src/containers/App.jsx index 0f9abf8a6a..ab76a44ed0 100644 --- a/src/containers/App.jsx +++ b/src/containers/App.jsx @@ -42,6 +42,7 @@ import { import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' import SectionDialog from 'components/Sections/SectionDialog' import { SentryRoutes } from 'lib/sentry' +import { Announcements } from 'components/Announcements/Announcements' window.flag = window.flag || flag window.minilog = minilog @@ -134,6 +135,7 @@ const App = ({ accounts, konnectors, triggers }) => { +
JSX.Element const ConfirmDialog: (props: ConfirmDialogProps) => JSX.Element - - export { ConfirmDialog, ConfirmDialogProps, Dialog, DialogProps } + const FixedActionsDialog: (props: DialogProps) => JSX.Element + + export { + ConfirmDialog, + FixedActionsDialog, + ConfirmDialogProps, + Dialog, + DialogProps + } } declare module 'cozy-ui/transpiled/react/providers/CozyTheme' { @@ -65,7 +72,11 @@ declare module 'cozy-ui/transpiled/react/providers/CozyTheme' { } declare module 'cozy-ui/transpiled/react/providers/I18n' { - export const useI18n: () => { t: (key: string) => string; lang: string } + export const useI18n: () => { + t: (key: string) => string + lang: string + f: (date: string, format: string) => string + } } declare module 'cozy-ui/transpiled/react/Buttons' { @@ -104,3 +115,9 @@ declare module 'cozy-ui/transpiled/react/styles' { declare module 'cozy-ui/react/Avatar/helpers' { export function nameToColor(name: string): string } + +declare module 'cozy-ui/transpiled/react/Typography' { + export default function Typography( + props: Record + ): JSX.Element +} diff --git a/src/global.d.ts b/src/global.d.ts index 8ee3724827..000a90362b 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -11,3 +11,5 @@ declare module 'assets/*' { const assets: string export default assets } + +declare module 'react-swipeable-views' diff --git a/src/hooks/useAnnouncements.tsx b/src/hooks/useAnnouncements.tsx new file mode 100644 index 0000000000..7cbb620a8b --- /dev/null +++ b/src/hooks/useAnnouncements.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' + +import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' +import flag from 'cozy-flags' +import { useClient } from 'cozy-client' + +import { + Announcement, + AnnouncementsConfigFlag +} from 'components/Announcements/types' +import { + getUnseenAnnouncements, + isAnnouncement +} from 'components/Announcements/helpers' +import { useAnnouncementsSettings } from './useAnnouncementsSettings' + +interface UseAnnouncementsProps { + canBeDisplayed: boolean +} + +const useAnnouncements = ({ + canBeDisplayed +}: UseAnnouncementsProps): Announcement[] => { + const { lang } = useI18n() + const client = useClient() + + const [fetchStatus, setFetchStatus] = useState('pending') + const [rawData, setRawData] = useState(null) + const [unseenData, setUnseenData] = useState([]) + const [hasBeenFiltered, setHasBeenFiltered] = useState(false) + const config = flag('home.announcements') + const { values, save } = useAnnouncementsSettings() + + useEffect(() => { + const fetchAnnouncements = async (): Promise => { + setFetchStatus('loading') + try { + const resp = (await client?.stackClient.fetchJSON( + 'GET', + `/remote/${config?.remoteDoctype}?lang=${lang}&channels=${config?.channels}` + )) as { data: unknown[] } + + if ( + !Array.isArray(resp.data) || + !resp.data.every(announcement => isAnnouncement(announcement)) + ) { + throw new Error('Invalid data') + } + + setRawData(resp.data) + setFetchStatus('loaded') + } catch (error) { + setFetchStatus('error') + } + } + + if ( + fetchStatus === 'pending' && + canBeDisplayed && + config?.remoteDoctype && + config?.channels + ) { + void fetchAnnouncements() + } + }, [client?.stackClient, config, fetchStatus, lang, values, canBeDisplayed]) + + useEffect(() => { + if (rawData !== null && values && !hasBeenFiltered) { + const uuidsSeen = values.seen ?? [] + const uuidsFromApi = rawData.map(({ attributes }) => attributes.uuid) + + // we only keep the announcements seen that are still returned by the API + // to limit the size of the seen array + const uuidsInCommon = uuidsSeen.filter(uuid => + uuidsFromApi.includes(uuid) + ) + const unseenData = getUnseenAnnouncements(rawData, uuidsInCommon) + + // we consider the first post to have been seen as soon as it is displayed + if (unseenData.length > 0) { + uuidsInCommon.push(unseenData[0].attributes.uuid) + } + save({ seen: uuidsInCommon }) + + setUnseenData(unseenData) + setHasBeenFiltered(true) + } + }, [hasBeenFiltered, rawData, values, save]) + + return unseenData +} + +export { useAnnouncements } diff --git a/src/hooks/useAnnouncementsImage.tsx b/src/hooks/useAnnouncementsImage.tsx new file mode 100644 index 0000000000..98cbbd8ba5 --- /dev/null +++ b/src/hooks/useAnnouncementsImage.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react' +import { useClient } from 'cozy-client' +import { AnnouncementsConfig } from 'components/Announcements/types' +import flag from 'cozy-flags' + +/** + * An hook to fetch an image from the announcements remote doctype. + * + * We need to fetch it inside using the url directly inside an tag + * because of the token to get throw the cozy-stack. + * + * @param url - The URL of the image to fetch inside announcements API + */ +const useAnnouncementsImage = (url: string | undefined): string | null => { + const client = useClient() + const [image, setImage] = useState(null) + const config = flag('home.announcements') + + useEffect(() => { + const fetchImage = async (): Promise => { + const urlWithoutPrefix = (url ?? '').replace('/uploads/', '') + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const fetchBinary = (await client?.stackClient.fetch( + 'GET', + `/remote/${config.remoteDoctype}.uploads?url=${urlWithoutPrefix}` + )) as Response + const blob = await fetchBinary.blob() + setImage(URL.createObjectURL(blob)) + } catch (error) { + setImage(null) + } + } + + if (!image && url) { + void fetchImage() + } + }, [client, config.remoteDoctype, image, url]) + + return image +} + +export { useAnnouncementsImage } diff --git a/src/hooks/useAnnouncementsSettings.tsx b/src/hooks/useAnnouncementsSettings.tsx new file mode 100644 index 0000000000..1e9d474d9d --- /dev/null +++ b/src/hooks/useAnnouncementsSettings.tsx @@ -0,0 +1,53 @@ +import { useSettings } from 'cozy-client' + +const defaultAnnouncements = { + dismissedAt: undefined, + seen: [] +} + +const useAnnouncementsSettings = (): { + values: { + dismissedAt?: string + seen: string[] + } + save: (data: { dismissedAt?: string; seen?: string[] }) => void +} => { + const { values, save } = useSettings('home', [ + 'announcements' + ]) as unknown as UseSettingsType + + const saveAnnouncement = (data: { + dismissedAt?: string + seen?: string[] + }): void => { + const announcements = { + ...(values?.announcements ?? defaultAnnouncements), + ...data + } + save({ + announcements + }) + } + + return { + values: values?.announcements ?? defaultAnnouncements, + save: saveAnnouncement + } +} + +interface UseSettingsType { + values?: { + announcements: { + dismissedAt: string + seen: string[] + } + } + save: (data: { + announcements: { + dismissedAt?: string + seen: string[] + } + }) => void +} + +export { useAnnouncementsSettings } diff --git a/src/locales/en.json b/src/locales/en.json index 0e5a02a628..e603ee47ce 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -600,5 +600,10 @@ "label_compact": "Compact Mosaic", "label_detailed": "Detailed List", "label_grouped": "Grouped in Folder" + }, + "AnnouncementsDialogContent": { + "dateFormat": "MM/DD/YYYY", + "next": "Next message", + "understand": "I understand" } } diff --git a/src/locales/fr.json b/src/locales/fr.json index 98e9dc1ae9..198772b28c 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -600,5 +600,10 @@ "label_compact": "Mosaïque condensée", "label_detailed": "Liste détaillée", "label_grouped": "Regroupé en dossier" + }, + "AnnouncementsDialogContent": { + "dateFormat": "DD/MM/YYYY", + "next": "Message suivant", + "understand": "J'ai compris" } -} \ No newline at end of file +}