diff --git a/app/components/ui/scroll-area.tsx b/app/components/ui/scroll-area.tsx new file mode 100644 index 000000000..8e3c33938 --- /dev/null +++ b/app/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; +import * as React from 'react'; + +import { cn } from '~/lib/utils'; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/app/features/notifications/notification-list-item.tsx b/app/features/notifications/notification-list-item.tsx new file mode 100644 index 000000000..bbc953aab --- /dev/null +++ b/app/features/notifications/notification-list-item.tsx @@ -0,0 +1,189 @@ +import { + Bell, + DollarSign, + Mail, + MailOpen, + MoreVertical, + Trash2, + UserPlus, +} from 'lucide-react'; +import { Button } from '~/components/ui/button'; +import { Card } from '~/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu'; +import { useSwipeActions } from '~/lib/use-swipeable'; +import { cn } from '~/lib/utils'; +import type { Notification } from './types'; +import { useNotificationStore } from './use-notification-store'; + +const icons = { + money_received: , + contact_request: , + announcement: , +}; + +const calculateTimeAgo = (createdAt: Date) => { + const now = new Date(); + const createdAtDate = new Date(createdAt); + const diffInSeconds = Math.floor( + (now.getTime() - createdAtDate.getTime()) / 1000, + ); + + if (diffInSeconds < 60) { + return `${diffInSeconds}s`; + } + if (diffInSeconds < 3600) { + return `${Math.floor(diffInSeconds / 60)}m`; + } + if (diffInSeconds < 86400) { + return `${Math.floor(diffInSeconds / 3600)}h`; + } + return `${Math.floor(diffInSeconds / 86400)}d`; +}; + +const ManageNotificationMenu = ({ + notification, +}: { notification: Notification }) => { + const { deleteNotification, toggleReadStatus } = useNotificationStore(); + + return ( + + + + + + toggleReadStatus(notification.id)}> + Mark as {notification.read ? 'unread' : 'read'} + + deleteNotification(notification.id)}> + Delete + + + + ); +}; + +type SwipeBackgroundProps = { + direction: 'left' | 'right'; + offset: number; + isRead: boolean; +}; + +/** The background that appears when swiping a notification. */ +const SwipeBackground = ({ + direction, + offset, + isRead, +}: SwipeBackgroundProps) => { + const isLeft = direction === 'left'; + const opacity = Math.max(0, Math.min(1, (isLeft ? -offset : offset) / 150)); + + if (isLeft) { + return ( +
+ +
+ ); + } + + return ( +
0 ? 1 : 0, + transform: `translateX(${offset}px)`, + }} + > + {isRead ? : } +
+ ); +}; + +export const NotificationListItem = ({ + notification: n, +}: { notification: Notification }) => { + const { deleteNotification, toggleReadStatus } = useNotificationStore(); + + const { offset, isTransitioning, swipeHandlers } = useSwipeActions({ + onSwipeLeft: () => deleteNotification(n.id), + onSwipeRight: () => toggleReadStatus(n.id), + }); + + return ( +
+
+ + +
+ + +
+
{icons[n.type]}
+
+
+
+
+ {n.title} +
+
+ + {calculateTimeAgo(n.timestamp)} + + +
+
+

+ {n.description} +

+
+ {n.actions && n.actions.length > 0 && ( +
+ {n.actions.map((a) => ( + + ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/app/features/notifications/notification-list.tsx b/app/features/notifications/notification-list.tsx new file mode 100644 index 000000000..c971e51ab --- /dev/null +++ b/app/features/notifications/notification-list.tsx @@ -0,0 +1,30 @@ +import { ScrollArea, ScrollBar } from '~/components/ui/scroll-area'; +import { NotificationListItem } from './notification-list-item'; +import type { Notification } from './types'; + +type NotificationsListProps = { + notifications: Notification[]; +}; + +export const NotificationsList = ({ + notifications, +}: NotificationsListProps) => { + return ( + <> + {notifications.length > 0 ? ( + +
+ {notifications.map((n) => ( + + ))} +
+ +
+ ) : ( +
+

No notifications

+
+ )} + + ); +}; diff --git a/app/features/notifications/types.ts b/app/features/notifications/types.ts new file mode 100644 index 000000000..229ba5f57 --- /dev/null +++ b/app/features/notifications/types.ts @@ -0,0 +1,42 @@ +// These types need to be reconsidered. This is just what I had from a while ago. +// Lets decided how notifcations should look, what possible notifcations will be, +// and what actinos will be able to be done on them. + +// NOTE: my idea for an action is to have buttons that will be shown on the notification. +// This could be things like 'view', 'Learn More', 'Claim', 'Add contact back', etc. + +export type NotificationAction = { + label: string; + action: () => void; +}; + +export type BaseNotification = { + id: string; + title: string; + description: string; + timestamp: Date; + read: boolean; + actions?: NotificationAction[]; +}; + +export type MoneyReceivedNotification = BaseNotification & { + type: 'money_received'; + amount: number; + from: string; +}; + +export type ContactRequestNotification = BaseNotification & { + type: 'contact_request'; + fromUserId: string; + fromUserName: string; +}; + +export type AnnouncementNotification = BaseNotification & { + type: 'announcement'; + priority: 'low' | 'medium' | 'high'; +}; + +export type Notification = + | MoneyReceivedNotification + | ContactRequestNotification + | AnnouncementNotification; diff --git a/app/features/notifications/use-notification-store.tsx b/app/features/notifications/use-notification-store.tsx new file mode 100644 index 000000000..dfab60a3b --- /dev/null +++ b/app/features/notifications/use-notification-store.tsx @@ -0,0 +1,97 @@ +import { create } from 'zustand'; +import type { Notification } from './types'; + +type NotificationStore = { + notifications: Notification[]; + deleteNotification: (id: string) => void; + toggleReadStatus: (id: string) => void; +}; + +const mockNotifications: Notification[] = [ + { + id: '1', + type: 'money_received', + title: 'Payment Received', + description: 'You received $50.00 from John Doe', + timestamp: new Date(Date.now() - 15 * 1000), // 15 seconds ago + read: false, + amount: 50.0, + from: 'John Doe', + actions: [ + { + label: 'View', + action: () => console.log('View transaction'), + }, + ], + }, + { + id: '2', + type: 'contact_request', + title: 'New Contact Request', + description: 'Jane Smith wants to add you as a contact', + timestamp: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago + read: false, + fromUserId: 'user123', + fromUserName: 'Jane Smith', + actions: [ + { + label: 'Accept', + action: () => console.log('Accept contact'), + }, + { + label: 'Decline', + action: () => console.log('Decline contact'), + }, + ], + }, + { + id: '3', + type: 'announcement', + title: 'New Feature Available', + description: 'Try our new QR code payment feature!', + timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000), // 5 hours ago + read: true, + priority: 'medium', + actions: [ + { + label: 'Show me', + action: () => console.log('Show me'), + }, + ], + }, + { + id: '4', + type: 'announcement', + title: 'Welcome to Boardwalk', + description: 'Thanks for joining! Here are some tips to get started.', + timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago + read: true, + priority: 'low', + actions: [ + { + label: 'Learn more', + action: () => console.log('Learn more'), + }, + ], + }, +]; + +export const useNotificationStore = create((set) => ({ + notifications: [ + // just to have more notifications for testing + ...mockNotifications, + ...mockNotifications.map((n) => ({ ...n, id: `${n.id}1` })), + ...mockNotifications.map((n) => ({ ...n, id: `${n.id}2` })), + ...mockNotifications.map((n) => ({ ...n, id: `${n.id}3` })), + ], + deleteNotification: (id) => + set((state) => ({ + notifications: state.notifications.filter((n) => n.id !== id), + })), + toggleReadStatus: (id) => + set((state) => ({ + notifications: state.notifications.map((n) => + n.id === id ? { ...n, read: !n.read } : n, + ), + })), +})); diff --git a/app/lib/use-swipeable/README.md b/app/lib/use-swipeable/README.md new file mode 100644 index 000000000..19e3c0b73 --- /dev/null +++ b/app/lib/use-swipeable/README.md @@ -0,0 +1,6 @@ +The useSwipeable hook and types are copied from https://github.com/FormidableLabs/react-swipeable + +For documentation, see https://commerce.nearform.com/open-source/react-swipeable + +The useSwipeActions hook is custom to this project. + diff --git a/app/lib/use-swipeable/index.ts b/app/lib/use-swipeable/index.ts new file mode 100644 index 000000000..070c5aa3f --- /dev/null +++ b/app/lib/use-swipeable/index.ts @@ -0,0 +1 @@ +export * from './use-swipe-actions'; diff --git a/app/lib/use-swipeable/types.ts b/app/lib/use-swipeable/types.ts new file mode 100644 index 000000000..06a739f9f --- /dev/null +++ b/app/lib/use-swipeable/types.ts @@ -0,0 +1,171 @@ +import type * as React from 'react'; + +export const LEFT = 'Left'; +export const RIGHT = 'Right'; +export const UP = 'Up'; +export const DOWN = 'Down'; +export type HandledEvents = React.MouseEvent | TouchEvent | MouseEvent; +export type Vector2 = [number, number]; +export type SwipeDirections = + | typeof LEFT + | typeof RIGHT + | typeof UP + | typeof DOWN; +export interface SwipeEventData { + /** + * Absolute displacement of swipe in x. Math.abs(deltaX); + */ + absX: number; + /** + * Absolute displacement of swipe in y. Math.abs(deltaY); + */ + absY: number; + /** + * Displacement of swipe in x. (current.x - initial.x) + */ + deltaX: number; + /** + * Displacement of swipe in y. (current.y - initial.y) + */ + deltaY: number; + /** + * Direction of swipe - Left | Right | Up | Down + */ + dir: SwipeDirections; + /** + * Source event. + */ + event: HandledEvents; + /** + * True for the first event of a tracked swipe. + */ + first: boolean; + /** + * Location where swipe started - [x, y]. + */ + initial: Vector2; + /** + * "Absolute velocity" (speed) - √(absX^2 + absY^2) / time + */ + velocity: number; + /** + * Velocity per axis - [ deltaX/time, deltaY/time ] + */ + vxvy: Vector2; +} + +export type SwipeCallback = (eventData: SwipeEventData) => void; +export type TapCallback = ({ event }: { event: HandledEvents }) => void; + +export type SwipeableDirectionCallbacks = { + /** + * Called after a DOWN swipe + */ + onSwipedDown: SwipeCallback; + /** + * Called after a LEFT swipe + */ + onSwipedLeft: SwipeCallback; + /** + * Called after a RIGHT swipe + */ + onSwipedRight: SwipeCallback; + /** + * Called after a UP swipe + */ + onSwipedUp: SwipeCallback; +}; + +export type SwipeableCallbacks = SwipeableDirectionCallbacks & { + /** + * Called at start of a tracked swipe. + */ + onSwipeStart: SwipeCallback; + /** + * Called after any swipe. + */ + onSwiped: SwipeCallback; + /** + * Called for each move event during a tracked swipe. + */ + onSwiping: SwipeCallback; + /** + * Called after a tap. A touch under the min distance, `delta`. + */ + onTap: TapCallback; + /** + * Called for `touchstart` and `mousedown`. + */ + onTouchStartOrOnMouseDown: TapCallback; + /** + * Called for `touchend` and `mouseup`. + */ + onTouchEndOrOnMouseUp: TapCallback; +}; + +// Configuration Options +export type ConfigurationOptionDelta = + | number + | { [key in Lowercase]?: number }; + +export interface ConfigurationOptions { + /** + * Min distance(px) before a swipe starts. **Default**: `10` + */ + delta: ConfigurationOptionDelta; + /** + * Prevents scroll during swipe in most cases. **Default**: `false` + */ + preventScrollOnSwipe: boolean; + /** + * Set a rotation angle. **Default**: `0` + */ + rotationAngle: number; + /** + * Track mouse input. **Default**: `false` + */ + trackMouse: boolean; + /** + * Track touch input. **Default**: `true` + */ + trackTouch: boolean; + /** + * Allowable duration of a swipe (ms). **Default**: `Infinity` + */ + swipeDuration: number; + /** + * Options for touch event listeners + */ + touchEventOptions: { passive: boolean }; +} + +export type SwipeableProps = Partial; + +export type SwipeablePropsWithDefaultOptions = Partial & + ConfigurationOptions; + +export interface SwipeableHandlers { + ref(element: HTMLElement | null): void; + onMouseDown?(event: React.MouseEvent): void; +} + +export type SwipeableState = { + cleanUpTouch?: () => void; + el?: HTMLElement; + eventData?: SwipeEventData; + first: boolean; + initial: Vector2; + start: number; + swiping: boolean; + xy: Vector2; +}; + +export type StateSetter = ( + state: SwipeableState, + props: SwipeablePropsWithDefaultOptions, +) => SwipeableState; +export type Setter = (stateSetter: StateSetter) => void; +export type AttachTouch = ( + el: HTMLElement, + props: SwipeablePropsWithDefaultOptions, +) => () => void; diff --git a/app/lib/use-swipeable/use-swipe-actions.ts b/app/lib/use-swipeable/use-swipe-actions.ts new file mode 100644 index 000000000..89a581d82 --- /dev/null +++ b/app/lib/use-swipeable/use-swipe-actions.ts @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { useSwipeable } from './use-swipeable'; + +type UseSwipeActionsProps = { + onSwipeLeft: () => void; + onSwipeRight: () => void; +}; + +type SwipeState = { + offset: number; + isTransitioning: boolean; + swipeHandlers: ReturnType; +}; + +/** + * This hook is used to add swipe actions to a component. + * It is a wrapper around the useSwipeable hook. + * + * @returns + * - offset: The offset of the component from the LEFT edge of the screen. + * - isTransitioning: Whether the component is transitioning. + * - swipeHandlers: The swipe handlers that should be passed to the component. + * + * @example + * ```tsx + * const { offset, isTransitioning, swipeHandlers } = useSwipeActions({ + * onSwipeLeft: () => {}, + * onSwipeRight: () => {}, + * }); + * + *
+ * This div will be swipable. + *
+ * ``` + */ +export const useSwipeActions = ({ + onSwipeLeft, + onSwipeRight, +}: UseSwipeActionsProps): SwipeState => { + const [offset, setOffset] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + + const resetSwipe = () => { + setIsTransitioning(true); + setOffset(0); + setTimeout(() => setIsTransitioning(false), 400); + }; + + const swipeHandlers = useSwipeable({ + onSwiping: (e) => { + if (Math.abs(e.deltaX) < 15) { + return; + } + if (!isTransitioning) { + setOffset(e.deltaX); + } + }, + onSwipedLeft: (e) => { + if (Math.abs(e.deltaX) > 150) { + onSwipeLeft(); + } + resetSwipe(); + }, + onSwipedRight: (e) => { + if (Math.abs(e.deltaX) > 150) { + onSwipeRight(); + } + resetSwipe(); + }, + onTouchEndOrOnMouseUp: () => { + resetSwipe(); + }, + trackMouse: false, + trackTouch: true, + preventScrollOnSwipe: false, + }); + + return { + offset, + isTransitioning, + swipeHandlers, + }; +}; diff --git a/app/lib/use-swipeable/use-swipeable.ts b/app/lib/use-swipeable/use-swipeable.ts new file mode 100644 index 000000000..154e88666 --- /dev/null +++ b/app/lib/use-swipeable/use-swipeable.ts @@ -0,0 +1,427 @@ +/* global document */ +import * as React from 'react'; +import { + type AttachTouch, + type ConfigurationOptions, + DOWN, + type HandledEvents, + LEFT, + RIGHT, + type Setter, + type SwipeCallback, + type SwipeDirections, + type SwipeEventData, + type SwipeableDirectionCallbacks, + type SwipeableHandlers, + type SwipeableProps, + type SwipeablePropsWithDefaultOptions, + type SwipeableState, + type TapCallback, + UP, + type Vector2, +} from './types'; + +export type { + LEFT, + RIGHT, + UP, + DOWN, + SwipeDirections, + SwipeEventData, + SwipeableDirectionCallbacks, + SwipeCallback, + TapCallback, + SwipeableHandlers, + SwipeableProps, + Vector2, +}; + +const defaultProps: ConfigurationOptions = { + delta: 10, + preventScrollOnSwipe: false, + rotationAngle: 0, + trackMouse: false, + trackTouch: true, + swipeDuration: Number.POSITIVE_INFINITY, + touchEventOptions: { passive: true }, +}; +const initialState: SwipeableState = { + first: true, + initial: [0, 0], + start: 0, + swiping: false, + xy: [0, 0], +}; +const mouseMove = 'mousemove'; +const mouseUp = 'mouseup'; +const touchEnd = 'touchend'; +const touchMove = 'touchmove'; +const touchStart = 'touchstart'; + +function getDirection( + absX: number, + absY: number, + deltaX: number, + deltaY: number, +): SwipeDirections { + if (absX > absY) { + if (deltaX > 0) { + return RIGHT; + } + return LEFT; + } + if (deltaY > 0) { + return DOWN; + } + return UP; +} + +function rotateXYByAngle(pos: Vector2, angle: number): Vector2 { + if (angle === 0) return pos; + const angleInRadians = (Math.PI / 180) * angle; + const x = + pos[0] * Math.cos(angleInRadians) + pos[1] * Math.sin(angleInRadians); + const y = + pos[1] * Math.cos(angleInRadians) - pos[0] * Math.sin(angleInRadians); + return [x, y]; +} + +function getHandlers( + set: Setter, + handlerProps: { trackMouse: boolean | undefined }, +): [ + { + ref: (element: HTMLElement | null) => void; + onMouseDown?: (event: React.MouseEvent) => void; + }, + AttachTouch, +] { + const onStart = (event: HandledEvents) => { + const isTouch = 'touches' in event; + // if more than a single touch don't track, for now... + if (isTouch && event.touches.length > 1) return; + + set((state, props) => { + // setup mouse listeners on document to track swipe since swipe can leave container + if (props.trackMouse && !isTouch) { + document.addEventListener(mouseMove, onMove); + document.addEventListener(mouseUp, onUp); + } + const { clientX, clientY } = isTouch ? event.touches[0] : event; + const xy = rotateXYByAngle([clientX, clientY], props.rotationAngle); + + props.onTouchStartOrOnMouseDown?.({ event }); + + return { + ...state, + ...initialState, + initial: xy.slice() as Vector2, + xy, + start: event.timeStamp || 0, + }; + }); + }; + + const onMove = (event: HandledEvents) => { + set((state, props) => { + const isTouch = 'touches' in event; + // Discount a swipe if additional touches are present after + // a swipe has started. + if (isTouch && event.touches.length > 1) { + return state; + } + + // if swipe has exceeded duration stop tracking + if (event.timeStamp - state.start > props.swipeDuration) { + return state.swiping ? { ...state, swiping: false } : state; + } + + const { clientX, clientY } = isTouch ? event.touches[0] : event; + const [x, y] = rotateXYByAngle([clientX, clientY], props.rotationAngle); + const deltaX = x - state.xy[0]; + const deltaY = y - state.xy[1]; + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + const time = (event.timeStamp || 0) - state.start; + const velocity = Math.sqrt(absX * absX + absY * absY) / (time || 1); + const vxvy: Vector2 = [deltaX / (time || 1), deltaY / (time || 1)]; + + const dir = getDirection(absX, absY, deltaX, deltaY); + + // if swipe is under delta and we have not started to track a swipe: skip update + const delta = + typeof props.delta === 'number' + ? props.delta + : props.delta[dir.toLowerCase() as Lowercase] || + defaultProps.delta; + if ( + absX < (delta as number) && + absY < (delta as number) && + !state.swiping + ) + return state; + + const eventData = { + absX, + absY, + deltaX, + deltaY, + dir, + event, + first: state.first, + initial: state.initial, + velocity, + vxvy, + }; + + // call onSwipeStart if present and is first swipe event + eventData.first && props.onSwipeStart && props.onSwipeStart(eventData); + + // call onSwiping if present + props.onSwiping?.(eventData); + + // track if a swipe is cancelable (handler for swiping or swiped(dir) exists) + // so we can call preventDefault if needed + let cancelablePageSwipe = false; + if ( + props.onSwiping || + props.onSwiped || + props[`onSwiped${dir}` as keyof SwipeableDirectionCallbacks] + ) { + cancelablePageSwipe = true; + } + + if ( + cancelablePageSwipe && + props.preventScrollOnSwipe && + props.trackTouch && + event.cancelable + ) { + event.preventDefault(); + } + + return { + ...state, + // first is now always false + first: false, + eventData, + swiping: true, + }; + }); + }; + + const onEnd = (event: HandledEvents) => { + set((state, props) => { + let eventData: SwipeEventData | undefined; + if (state.swiping && state.eventData) { + // if swipe is less than duration fire swiped callbacks + if (event.timeStamp - state.start < props.swipeDuration) { + eventData = { ...state.eventData, event }; + props.onSwiped?.(eventData); + + const onSwipedDir = + props[ + `onSwiped${eventData.dir}` as keyof SwipeableDirectionCallbacks + ]; + onSwipedDir?.(eventData); + } + } else { + props.onTap?.({ event }); + } + + props.onTouchEndOrOnMouseUp?.({ event }); + + return { ...state, ...initialState, eventData }; + }); + }; + + const cleanUpMouse = () => { + // safe to just call removeEventListener + document.removeEventListener(mouseMove, onMove); + document.removeEventListener(mouseUp, onUp); + }; + + const onUp = (e: HandledEvents) => { + cleanUpMouse(); + onEnd(e); + }; + + /** + * The value of passive on touchMove depends on `preventScrollOnSwipe`: + * - true => { passive: false } + * - false => { passive: true } // Default + * + * NOTE: When preventScrollOnSwipe is true, we attempt to call preventDefault to prevent scroll. + * + * props.touchEventOptions can also be set for all touch event listeners, + * but for `touchmove` specifically when `preventScrollOnSwipe` it will + * supersede and force passive to false. + * + */ + const attachTouch: AttachTouch = (el, props) => { + // biome-ignore lint/suspicious/noEmptyBlockStatements: + let cleanup = () => {}; + if (el?.addEventListener) { + const baseOptions = { + ...defaultProps.touchEventOptions, + ...props.touchEventOptions, + }; + // attach touch event listeners and handlers + const tls: [ + typeof touchStart | typeof touchMove | typeof touchEnd, + (e: HandledEvents) => void, + { passive: boolean }, + ][] = [ + [touchStart, onStart, baseOptions], + // preventScrollOnSwipe option supersedes touchEventOptions.passive + [ + touchMove, + onMove, + { + ...baseOptions, + ...(props.preventScrollOnSwipe ? { passive: false } : {}), + }, + ], + [touchEnd, onEnd, baseOptions], + ]; + tls.forEach(([e, h, o]) => el.addEventListener(e, h, o)); + // return properly scoped cleanup method for removing listeners, options not required + cleanup = () => tls.forEach(([e, h]) => el.removeEventListener(e, h)); + } + return cleanup; + }; + + const onRef = (el: HTMLElement | null) => { + // "inline" ref functions are called twice on render, once with null then again with DOM element + // ignore null here + if (el === null) return; + set((state, props) => { + // if the same DOM el as previous just return state + if (state.el === el) return state; + + const addState: { cleanUpTouch?: () => void } = {}; + // if new DOM el clean up old DOM and reset cleanUpTouch + if (state.el && state.el !== el && state.cleanUpTouch) { + state.cleanUpTouch(); + addState.cleanUpTouch = void 0; + } + // only attach if we want to track touch + if (props.trackTouch && el) { + addState.cleanUpTouch = attachTouch(el, props); + } + + // store event attached DOM el for comparison, clean up, and re-attachment + return { ...state, el, ...addState }; + }); + }; + + // set ref callback to attach touch event listeners + const output: { ref: typeof onRef; onMouseDown?: typeof onStart } = { + ref: onRef, + }; + + // if track mouse attach mouse down listener + if (handlerProps.trackMouse) { + output.onMouseDown = onStart; + } + + return [output, attachTouch]; +} + +function updateTransientState( + state: SwipeableState, + props: SwipeablePropsWithDefaultOptions, + previousProps: SwipeablePropsWithDefaultOptions, + attachTouch: AttachTouch, +) { + // if trackTouch is off or there is no el, then remove handlers if necessary and exit + if (!props.trackTouch || !state.el) { + if (state.cleanUpTouch) { + state.cleanUpTouch(); + } + + return { + ...state, + cleanUpTouch: undefined, + }; + } + + // trackTouch is on, so if there are no handlers attached, attach them and exit + if (!state.cleanUpTouch) { + return { + ...state, + cleanUpTouch: attachTouch(state.el, props), + }; + } + + // trackTouch is on and handlers are already attached, so if preventScrollOnSwipe changes value, + // remove and reattach handlers (this is required to update the passive option when attaching + // the handlers) + if ( + props.preventScrollOnSwipe !== previousProps.preventScrollOnSwipe || + props.touchEventOptions.passive !== previousProps.touchEventOptions.passive + ) { + state.cleanUpTouch(); + + return { + ...state, + cleanUpTouch: attachTouch(state.el, props), + }; + } + + return state; +} + +export function useSwipeable(options: SwipeableProps): SwipeableHandlers { + const { trackMouse } = options; + const transientState = React.useRef({ ...initialState }); + const transientProps = React.useRef({ + ...defaultProps, + }); + + // track previous rendered props + const previousProps = React.useRef({ + ...transientProps.current, + }); + previousProps.current = { ...transientProps.current }; + + // update current render props & defaults + transientProps.current = { + ...defaultProps, + ...options, + }; + // Force defaults for config properties + let defaultKey: keyof ConfigurationOptions; + for (defaultKey in defaultProps) { + if (transientProps.current[defaultKey] === void 0) { + (transientProps.current[ + defaultKey + ] as ConfigurationOptions[typeof defaultKey]) = defaultProps[defaultKey]; + } + } + + const [handlers, attachTouch] = React.useMemo( + () => + getHandlers( + (stateSetter) => { + const nextState = stateSetter( + transientState.current, + transientProps.current, + ); + transientState.current = nextState; + return nextState; + }, + { trackMouse }, + ), + [trackMouse], + ); + + transientState.current = updateTransientState( + transientState.current, + transientProps.current, + previousProps.current, + attachTouch, + ); + + return handlers; +} diff --git a/app/routes/_protected.notifications.tsx b/app/routes/_protected.notifications.tsx new file mode 100644 index 000000000..a4d221be5 --- /dev/null +++ b/app/routes/_protected.notifications.tsx @@ -0,0 +1,25 @@ +import { + ClosePageButton, + Page, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { NotificationsList } from '~/features/notifications/notification-list'; +import { useNotificationStore } from '~/features/notifications/use-notification-store'; + +export default function notificationsPage() { + const notifications = useNotificationStore((state) => state.notifications); + + return ( + + + + Notifications + + + + + + ); +} diff --git a/bun.lockb b/bun.lockb index abad67e58..ee3bf6d75 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a5521ae4b..59e17fb42 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-label": "2.1.1", "@radix-ui/react-radio-group": "1.2.3", + "@radix-ui/react-scroll-area": "1.2.3", "@radix-ui/react-select": "2.1.6", "@radix-ui/react-separator": "1.1.0", "@radix-ui/react-slot": "1.1.1",