diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index 8c9adbad27..dede4db7be 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -337,6 +337,18 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { next() }) + webRouter.use(function (req, res, next) { + const KEY_PREFIX = 'readnotif-' + const dismissedNotifications = [] + for (const cookieName in req.cookies) { + if (cookieName.startsWith(KEY_PREFIX)) { + dismissedNotifications.push(cookieName.slice(KEY_PREFIX.length)) + } + } + res.locals.dismissedNotifications = dismissedNotifications + next() + }) + webRouter.use(function (req, res, next) { res.locals.ExposedSettings = { isOverleaf: Settings.overleaf != null, diff --git a/services/web/app/views/_mixins/ciam_mixins.pug b/services/web/app/views/_mixins/ciam_mixins.pug index 8fba22064d..c3ee562e00 100644 --- a/services/web/app/views/_mixins/ciam_mixins.pug +++ b/services/web/app/views/_mixins/ciam_mixins.pug @@ -109,16 +109,26 @@ mixin ciamErrorNotification .notification-content block -mixin ciamInfoNotification - .notification.notification-ds.notification-type-info( - role='alert' - aria-live='polite' - ) - .notification-icon - ph-info(aria-hidden='true') - .notification-content-and-cta - .notification-content - block +mixin ciamInfoNotification(options) + - var {dismissId, dismissCookiePaths} = options ?? {} + if dismissId && dismissedNotifications.includes(dismissId) + // Notification has been dismissed, do not render + else + .notification.notification-ds.notification-type-info( + role='alert' + aria-live='polite' + data-ol-dismiss-id=dismissId + data-ol-dismiss-cookie-paths=dismissCookiePaths && dismissCookiePaths.join(',') + ) + .notification-icon + ph-info(aria-hidden='true') + .notification-content-and-cta + .notification-content + block + if dismissId + .notification-close-btn + button(aria-label=translate('close') data-ol-dismiss-button) + ph-x mixin ciamOrDivider p.ciam-login-register-or-text-container= translate('login_register_or').toUpperCase() diff --git a/services/web/app/views/_mixins/notification.pug b/services/web/app/views/_mixins/notification.pug index 807e13ab60..b558849ea1 100644 --- a/services/web/app/views/_mixins/notification.pug +++ b/services/web/app/views/_mixins/notification.pug @@ -12,27 +12,35 @@ mixin notificationIcon(type) +material-symbol('warning') mixin notification(options) - - var {ariaLive, id, type, title, content, disclaimer, className} = options + - var {ariaLive, id, type, title, content, disclaimer, className, dismissId, dismissCookiePaths} = options ?? {} - var classNames = `notification notification-type-${type} ${className ? className : ''} ${isActionBelowContent ? 'notification-cta-below-content' : ''}` - div(aria-live=ariaLive role='alert' id=id class=classNames) - .notification-icon - +notificationIcon(type) - .notification-content-and-cta - .notification-content - if title - p - b #{title} - | !{content} - block - //- TODO: handle action - //- if action - //- .notification-cta - if disclaimer - .notification-disclaimer #{disclaimer} - //- TODO: handle dismissible notifications - //- TODO: handle onDismiss - //- if isDismissible - //- .notification-close-btn - //- button(aria-label=translate('close')) - //- +material-symbol("close") + if dismissId && dismissedNotifications.includes(dismissId) + //- Notification has been dismissed, do not render + else + div( + aria-live=ariaLive + role='alert' + id=id + class=classNames + data-ol-dismiss-id=dismissId + data-ol-dismiss-cookie-paths=dismissCookiePaths && dismissCookiePaths.join(',') + ) + .notification-icon + +notificationIcon(type) + .notification-content-and-cta + .notification-content + if title + p + b #{title} + | !{content} + block + //- TODO: handle action + //- if action + //- .notification-cta + if disclaimer + .notification-disclaimer #{disclaimer} + if dismissId + .notification-close-btn + button(aria-label=translate('close') data-ol-dismiss-button) + +material-symbol('close') diff --git a/services/web/frontend/js/bootstrap.ts b/services/web/frontend/js/bootstrap.ts index 2525e080d2..cd80f4818d 100644 --- a/services/web/frontend/js/bootstrap.ts +++ b/services/web/frontend/js/bootstrap.ts @@ -1,4 +1,5 @@ import './features/bookmarkable-tab/index' import './features/tooltip/index' import './features/navbar/index' +import './features/notifications/dismissible-notifications' import 'bootstrap' diff --git a/services/web/frontend/js/features/form-helpers/form-phosphor-icons.ts b/services/web/frontend/js/features/form-helpers/form-phosphor-icons.ts index 3ebe910702..8503b8a3f2 100644 --- a/services/web/frontend/js/features/form-helpers/form-phosphor-icons.ts +++ b/services/web/frontend/js/features/form-helpers/form-phosphor-icons.ts @@ -4,3 +4,4 @@ import '@phosphor-icons/webcomponents/PhEye' import '@phosphor-icons/webcomponents/PhInfo' import '@phosphor-icons/webcomponents/PhEyeSlash' import '@phosphor-icons/webcomponents/PhWarningCircle' +import '@phosphor-icons/webcomponents/PhX' diff --git a/services/web/frontend/js/features/notifications/dismissible-notifications.ts b/services/web/frontend/js/features/notifications/dismissible-notifications.ts new file mode 100644 index 0000000000..18c3c01e75 --- /dev/null +++ b/services/web/frontend/js/features/notifications/dismissible-notifications.ts @@ -0,0 +1,49 @@ +import getMeta from '@/utils/meta' + +const KEY_PREFIX = 'readnotif-' + +function setDismissedNotification(dismissId: string, cookiePaths: string[]) { + const name = `${KEY_PREFIX}${dismissId}` + const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain + const oneYearInSeconds = 60 * 60 * 24 * 365 + + for (const path of cookiePaths) { + const cookieAttributes = + `; path=${path}` + + '; domain=' + + cookieDomain + + '; max-age=' + + oneYearInSeconds + + '; SameSite=Lax; Secure' + document.cookie = `${name}=1;${cookieAttributes}` + } +} + +function hydrateDismissibleNotification(notification: HTMLElement) { + const dismissId = notification.dataset.olDismissId + if (!dismissId) return + + if (!notification.dataset.olDismissCookiePaths) { + throw new Error( + `Dismissible notifications must have a data-ol-dismiss-cookie-paths attribute ("${dismissId}").` + ) + } + + const dismissCookiePaths = + notification.dataset.olDismissCookiePaths.split(',') + + const dismissButton = notification.querySelector( + '[data-ol-dismiss-button]' + ) + + if (dismissButton) { + dismissButton.addEventListener('click', () => { + setDismissedNotification(dismissId, dismissCookiePaths) + notification.hidden = true + }) + } +} + +document + .querySelectorAll('[data-ol-dismiss-id]') + .forEach(hydrateDismissibleNotification) diff --git a/services/web/frontend/stylesheets/ds/components/notification.scss b/services/web/frontend/stylesheets/ds/components/notification.scss index f462adc62c..6086d6f84a 100644 --- a/services/web/frontend/stylesheets/ds/components/notification.scss +++ b/services/web/frontend/stylesheets/ds/components/notification.scss @@ -27,4 +27,32 @@ &.notification-type-info { background-color: var(--ds-color-blue-50); } + + .notification-close-btn { + align-self: flex-start; + padding: var(--ds-spacing-400) 0 0; + height: auto; + + button { + @include ds-heading-sm-regular; + + color: var(--ds-color-neutral-950); + padding: var(--ds-spacing-150); + + &:hover, + &:focus { + background-color: var(--ds-color-blue-100); + } + + &:active { + background-color: var(--ds-color-blue-200); + } + + &:focus-visible { + background: transparent; + + @include ds-focus-outline; + } + } + } }