Skip to content

Commit

Permalink
feat(global-message): dismissable messages
Browse files Browse the repository at this point in the history
  • Loading branch information
mathiazom committed Jan 21, 2025
1 parent 9a732ec commit 73c2754
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 30 deletions.
30 changes: 17 additions & 13 deletions src/components/message-box/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type MessageBoxProps = {
message: string;
noStatusIcon?: boolean;
onClick?: () => void;
onDismiss?: () => void;
borderRadius?: boolean;
subtle?: boolean;
};
Expand All @@ -32,6 +33,7 @@ export const MessageBox = ({
textId,
title,
onClick,
onDismiss,
borderRadius = true,
subtle = false
}: MessageBoxProps) => {
Expand All @@ -58,31 +60,20 @@ export const MessageBox = ({
>
{!noStatusIcon && (
<MonoIcon
className={style.icon}
icon={messageTypeToMonoIcon(type)}
overrideMode={overrideMode}
/>
)}
<div className={style.content}>
{title && (
<Typo.h2 className={style.title} textType="body__primary--bold">
{title}
</Typo.h2>
)}
<Typo.p
className={style.body}
textType="body__primary"
id={textId}
{...aria}
>
{title && <Typo.h2 textType="body__primary--bold">{title}</Typo.h2>}
<Typo.p textType="body__primary" id={textId} {...aria}>
{message}
</Typo.p>
{onClick && (
<Button
mode="transparent--underline"
onClick={() => onClick()}
title={t(dictionary.readMore)}
className={style.messageBox__button}
size="compact"
buttonProps={{
'aria-label': `${message}${screenReaderPause}${t(
Expand All @@ -92,6 +83,19 @@ export const MessageBox = ({
/>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className={style.message__close}
>
<MonoIcon
icon={'actions/Close'}
aria-label={t(dictionary.close)}
overrideMode={overrideMode}
/>
</button>
)}
</div>
);
};
Expand Down
31 changes: 17 additions & 14 deletions src/components/message-box/message-box.module.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
.container {
display: flex;
padding: token('spacing.medium');
padding: token('spacing.large');
gap: token('spacing.medium');
border: token('border.width.medium') solid;
align-items: flex-start;
}
.borderRadius {
border-radius: token('border.radius.regular');
Expand All @@ -14,17 +16,18 @@
display: flex;
flex-direction: column;
justify-content: space-between;
}
.title {
margin-bottom: token('spacing.small');
padding: token('spacing.small');
}
.body {
padding: token('spacing.small');
}
.messageBox__button {
padding: token('spacing.small');
}
.icon {
margin-top: token('spacing.small');
flex-grow: 1;
gap: token('spacing.small');
}

.message__close {
border: 0;
background: 0;
display: block;
padding: 0 token('spacing.medium');
cursor: pointer;
}

.message__close img {
display: block;
}
64 changes: 62 additions & 2 deletions src/modules/global-messages/context.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
createContext,
PropsWithChildren,
useCallback,
useContext,
useEffect,
useState,
Expand All @@ -10,33 +11,92 @@ import { collection, onSnapshot, query, where } from '@firebase/firestore';
import app from '@atb/modules/firebase/firebase';
import { getFirestore } from 'firebase/firestore';
import { globalMessageConverter } from './converters';
import useLocalStorage from '@atb/utils/use-localstorage.ts';
import { useNow } from '@atb/utils/use-now.ts';

type GlobalMessagesState = {
activeGlobalMessages: GlobalMessageType[];
dismissGlobalMessage: (message: GlobalMessageType) => void;
};

const GlobalMessageContext = createContext<GlobalMessagesState | undefined>(
undefined,
);

type DismissedGlobalMessage = {
id: string;
};

const DISMISSED_GLOBAL_MESSAGES_KEY = 'dismissedGlobalMessages';

export type GlobalMessagesContextProps = PropsWithChildren<{}>;
export function GlobalMessageContextProvider({
children,
}: GlobalMessagesContextProps) {
const [activeGlobalMessages, setActiveGlobalMessages] = useState<
GlobalMessageType[]
>([]);
const [dismissedGlobalMessages, setDismissedGlobalMessages] = useLocalStorage<
DismissedGlobalMessage[]
>(DISMISSED_GLOBAL_MESSAGES_KEY, []);
const now = useNow();

useEffect(() => {
return subscribeToActiveGlobalMessagesFromFirestore(
setActiveGlobalMessages,
(messages) => setActiveGlobalMessages(messages.filter(message => {
return !dismissedGlobalMessages.some(
(dismissedGlobalMessage) =>
message.isDismissable &&
dismissedGlobalMessage.id === message.id,
);
})),
);
}, []);
}, [dismissedGlobalMessages]);

const isPastEndDate = useCallback(
(endDate: Date | undefined) => {
return endDate ? endDate.getTime() < now : false;
},
[now],
);

const dismissGlobalMessage = useCallback(
(message: GlobalMessageType) => {
if (!message.isDismissable) return;

const activeMessages = activeGlobalMessages.filter(
(message) => !isPastEndDate(message.endDate),
);

/**
* To make sure that the list of dismissed IDs in local storage doesn't
* grow forever, we clean up inactive global messages before adding a new one.
*/
const updatedDismissedGlobalMessages = dismissedGlobalMessages
.filter((dismissedMessage) =>
dismissedMessage.id !== message.id && activeMessages.some(
(activeMessage) => dismissedMessage.id === activeMessage.id,
),
)
.map((message) => ({ id: message.id }));

setDismissedGlobalMessages(
updatedDismissedGlobalMessages.concat({ id: message.id }),
);
},
[
activeGlobalMessages,
setDismissedGlobalMessages,
dismissedGlobalMessages,
isPastEndDate,
],
);

return (
<GlobalMessageContext.Provider
value={{
activeGlobalMessages,
dismissGlobalMessage,
}}
>
{children}
Expand Down
3 changes: 2 additions & 1 deletion src/modules/global-messages/global-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type GlobalMessagesProps = {

export function GlobalMessages({ context, className }: GlobalMessagesProps) {
const { language } = useTranslation();
const { activeGlobalMessages } = useActiveGlobalMessages();
const { activeGlobalMessages, dismissGlobalMessage } = useActiveGlobalMessages();

if (!activeGlobalMessages.length) return null;

Expand Down Expand Up @@ -47,6 +47,7 @@ export function GlobalMessages({ context, className }: GlobalMessagesProps) {
title={getTextForLanguage(message.title, language)}
message={getTextForLanguage(message.body, language) ?? ''}
subtle={message.subtle}
onDismiss={message.isDismissable ? () => dismissGlobalMessage(message) : undefined }
/>
</motion.div>
))}
Expand Down
27 changes: 27 additions & 0 deletions src/utils/use-interval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {useRef, useEffect} from 'react';

export default function useInterval(
callback: Function,
delay: number,
deps: React.DependencyList = [],
disabled: boolean = false,
) {
const savedCallback = useRef<Function>(() => {});

// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);

// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null && !disabled) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [delay, disabled, ...deps]);
}
43 changes: 43 additions & 0 deletions src/utils/use-localstorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {useCallback, useState} from 'react';

export default function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// If error also return initialValue
console.log(error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = useCallback(
(value: T) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.log(error);
}
},
[key, storedValue],
);
return [storedValue, setValue];
}
8 changes: 8 additions & 0 deletions src/utils/use-now.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {useState} from 'react';
import useInterval from '@atb/utils/use-interval';

export function useNow(milliseconds: number = 2500): number {
const [now, setNow] = useState<number>(Date.now());
useInterval(() => setNow(Date.now()), milliseconds);
return now;
}

0 comments on commit 73c2754

Please sign in to comment.