diff --git a/__tests__/components/brain/notifications/item/NotificationItem.test.tsx b/__tests__/components/brain/notifications/item/NotificationItem.test.tsx index 12104112e3..00f1c9d726 100644 --- a/__tests__/components/brain/notifications/item/NotificationItem.test.tsx +++ b/__tests__/components/brain/notifications/item/NotificationItem.test.tsx @@ -6,6 +6,7 @@ import { ApiNotificationCause } from '@/generated/models/ApiNotificationCause'; jest.mock('@/components/brain/notifications/drop-quoted/NotificationDropQuoted', () => ({ __esModule: true, default: () =>
})); jest.mock('@/components/brain/notifications/drop-replied/NotificationDropReplied', () => ({ __esModule: true, default: () =>
})); jest.mock('@/components/brain/notifications/priority-alert/NotificationPriorityAlert', () => ({ __esModule: true, default: () =>
})); +jest.mock('@/components/brain/notifications/identity-rating/NotificationIdentityRating', () => ({ __esModule: true, default: () =>
})); describe('NotificationItem', () => { const base = { id: '1' } as any; @@ -23,4 +24,14 @@ describe('NotificationItem', () => { render(); expect(screen.getByTestId('priority-alert')).toBeInTheDocument(); }); + + it('renders identity rating component for IdentityRep', () => { + render(); + expect(screen.getByTestId('identity-rating')).toBeInTheDocument(); + }); + + it('renders identity rating component for IdentityNic', () => { + render(); + expect(screen.getByTestId('identity-rating')).toBeInTheDocument(); + }); }); diff --git a/components/brain/notifications/NotificationItem.tsx b/components/brain/notifications/NotificationItem.tsx index c651f91e6e..8b97d53d68 100644 --- a/components/brain/notifications/NotificationItem.tsx +++ b/components/brain/notifications/NotificationItem.tsx @@ -2,12 +2,17 @@ import type { DropInteractionParams } from "@/components/waves/drops/Drop"; import { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; -import type { TypedNotification } from "@/types/feed.types"; +import type { + INotificationGeneric, + TypedNotification, +} from "@/types/feed.types"; import { memo } from "react"; import NotificationAllDrops from "./all-drops/NotificationAllDrops"; import NotificationDropQuoted from "./drop-quoted/NotificationDropQuoted"; import NotificationDropReplied from "./drop-replied/NotificationDropReplied"; +import NotificationGeneric from "./generic/NotificationGeneric"; import NotificationIdentityMentioned from "./identity-mentioned/NotificationIdentityMentioned"; +import NotificationIdentityRating from "./identity-rating/NotificationIdentityRating"; import NotificationIdentitySubscribed from "./identity-subscribed/NotificationIdentitySubscribed"; import NotificationPriorityAlert from "./priority-alert/NotificationPriorityAlert"; import NotificationWaveCreated from "./wave-created/NotificationWaveCreated"; @@ -74,6 +79,9 @@ function NotificationItemComponent({ ); case ApiNotificationCause.IdentitySubscribed: return ; + case ApiNotificationCause.IdentityRep: + case ApiNotificationCause.IdentityNic: + return ; case ApiNotificationCause.WaveCreated: return ; case ApiNotificationCause.AllDrops: @@ -97,7 +105,11 @@ function NotificationItemComponent({ /> ); default: - return
; + return ( + + ); } }; diff --git a/components/brain/notifications/NotificationItems.tsx b/components/brain/notifications/NotificationItems.tsx index 9dfbe3c265..aa9fe57fa2 100644 --- a/components/brain/notifications/NotificationItems.tsx +++ b/components/brain/notifications/NotificationItems.tsx @@ -35,7 +35,7 @@ function NotificationItemsComponent({ ); return ( -
+
{keyedNotifications.map(({ notification, key, domId }) => (
([]); const highlightRef = useRef(null); const activeIndexRef = useRef(0); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); const { connectedProfile } = useContext(AuthContext); const prefetchNotifications = usePrefetchNotifications(); + const checkScroll = useCallback(() => { + const container = containerRef.current; + if (!container) return; + + const { scrollLeft, scrollWidth, clientWidth } = container; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + checkScroll(); + container.addEventListener("scroll", checkScroll); + window.addEventListener("resize", checkScroll); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(() => { + checkScroll(); + }); + resizeObserver.observe(container); + } + + return () => { + container.removeEventListener("scroll", checkScroll); + window.removeEventListener("resize", checkScroll); + resizeObserver?.disconnect(); + }; + }, [checkScroll]); + + const scrollLeft = () => { + const container = containerRef.current; + if (!container) return; + container.scrollBy({ left: -150, behavior: "smooth" }); + }; + + const scrollRight = () => { + const container = containerRef.current; + if (!container) return; + container.scrollBy({ left: 150, behavior: "smooth" }); + }; + const handleHover = (filter: NotificationFilter) => { if (!connectedProfile) return; prefetchNotifications({ identity: connectedProfile.handle, - cause: filter.cause, + cause: filter.cause.length > 0 ? filter.cause : null, pages: 1, }); }; @@ -130,10 +195,10 @@ export default function NotificationsCauseFilter({ const isActive = (filter: NotificationFilter) => activeFilter === filter; return ( -
+
))}
+ {canScrollLeft && ( + <> +
+ + + )} + {canScrollRight && ( + <> +
+ + + )}
); } diff --git a/components/brain/notifications/generic/NotificationGeneric.tsx b/components/brain/notifications/generic/NotificationGeneric.tsx new file mode 100644 index 0000000000..b07f84170c --- /dev/null +++ b/components/brain/notifications/generic/NotificationGeneric.tsx @@ -0,0 +1,102 @@ +import type { INotificationGeneric } from "@/types/feed.types"; +import NotificationHeader from "../subcomponents/NotificationHeader"; +import NotificationTimestamp from "../subcomponents/NotificationTimestamp"; + +function formatCause(cause: string): string { + return cause + .replaceAll("_", " ") + .toLowerCase() + .replaceAll(/\b\w/g, (c) => c.toUpperCase()); +} + +function formatContextValue(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return null; +} + +function ContextDetails({ + context, +}: { + readonly context: Record | undefined; +}) { + if (!context || Object.keys(context).length === 0) return null; + + const displayableEntries = Object.entries(context) + .map(([key, value]) => [key, formatContextValue(value)] as const) + .filter((entry): entry is [string, string] => entry[1] !== null); + + if (displayableEntries.length === 0) return null; + + return ( + <> + + • + +
+ {displayableEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+ + ); +} + +function NotificationContent({ + causeLabel, + createdAt, + context, +}: { + readonly causeLabel: string; + readonly createdAt: number; + readonly context: Record | undefined; +}) { + return ( + <> + + {causeLabel} + + + + + ); +} + +export default function NotificationGeneric({ + notification, +}: { + readonly notification: INotificationGeneric; +}) { + const causeLabel = formatCause(notification.cause); + + if (notification.related_identity) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+ + {causeLabel} + + +
+ +
+ ); +} diff --git a/components/brain/notifications/hooks/useNotificationsController.ts b/components/brain/notifications/hooks/useNotificationsController.ts index 4d7965da0b..42763fa485 100644 --- a/components/brain/notifications/hooks/useNotificationsController.ts +++ b/components/brain/notifications/hooks/useNotificationsController.ts @@ -153,7 +153,7 @@ export const useNotificationsController = activeProfileProxy: !!activeProfileProxy, limit: "30", reverse: true, - cause: activeFilter?.cause, + cause: activeFilter?.cause?.length ? activeFilter.cause : null, }); useEffect(() => { diff --git a/components/brain/notifications/identity-rating/NotificationIdentityRating.tsx b/components/brain/notifications/identity-rating/NotificationIdentityRating.tsx new file mode 100644 index 0000000000..28d1f8d51c --- /dev/null +++ b/components/brain/notifications/identity-rating/NotificationIdentityRating.tsx @@ -0,0 +1,113 @@ +import { AuthContext } from "@/components/auth/Auth"; +import { UserFollowBtnSize } from "@/components/user/utils/UserFollowBtn"; +import { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import type { + INotificationIdentityNic, + INotificationIdentityRep, +} from "@/types/feed.types"; +import Link from "next/link"; +import { useContext } from "react"; +import NotificationsFollowBtn from "../NotificationsFollowBtn"; +import NotificationHeader from "../subcomponents/NotificationHeader"; +import NotificationTimestamp from "../subcomponents/NotificationTimestamp"; + +interface NotificationIdentityRatingProps { + readonly notification: INotificationIdentityRep | INotificationIdentityNic; +} + +function getRatingColor(rating: number): string { + if (rating > 0) return "tw-text-green"; + if (rating < 0) return "tw-text-red"; + return "tw-text-iron-400"; +} + +function formatRating(rating: number): string { + const prefix = rating > 0 ? "+" : ""; + return `${prefix}${formatNumberWithCommas(rating)}`; +} + +export default function NotificationIdentityRating({ + notification, +}: NotificationIdentityRatingProps) { + const { connectedProfile } = useContext(AuthContext); + const isRep = notification.cause === ApiNotificationCause.IdentityRep; + const { amount, total } = notification.additional_context; + const category = + "category" in notification.additional_context + ? notification.additional_context.category + : null; + + const myHandle = connectedProfile?.handle; + const getProfileLink = (): string | null => { + if (!myHandle) return null; + return isRep ? `/${myHandle}/rep` : `/${myHandle}/identity`; + }; + const linkHref = getProfileLink(); + + const ratingLabel = isRep ? "REP" : "NIC"; + + return ( +
+ + } + > + + updated your{" "} + + {linkHref ? ( + + + {ratingLabel} + + {category && ( + + {" "} + for category '{category}' + + )} + by + + {formatRating(amount)} + + + ) : ( + + + {ratingLabel} + + {category && ( + + {" "} + for category '{category}' + + )} + by + + {formatRating(amount)} + + + )} + + + • + + New Total:{" "} + + {formatNumberWithCommas(total)} + + + + +
+ ); +} diff --git a/components/brain/notifications/index.tsx b/components/brain/notifications/index.tsx index 9ea109703d..8752ded81c 100644 --- a/components/brain/notifications/index.tsx +++ b/components/brain/notifications/index.tsx @@ -16,14 +16,16 @@ interface NotificationsProps { const NOTIFICATION_CAUSE_PRIORITY: Record = { [ApiNotificationCause.IdentitySubscribed]: 0, [ApiNotificationCause.IdentityMentioned]: 1, - [ApiNotificationCause.DropQuoted]: 2, - [ApiNotificationCause.DropReplied]: 3, - [ApiNotificationCause.DropVoted]: 4, - [ApiNotificationCause.DropBoosted]: 5, - [ApiNotificationCause.DropReacted]: 6, - [ApiNotificationCause.WaveCreated]: 7, - [ApiNotificationCause.AllDrops]: 8, - [ApiNotificationCause.PriorityAlert]: 9, + [ApiNotificationCause.IdentityRep]: 2, + [ApiNotificationCause.IdentityNic]: 3, + [ApiNotificationCause.DropQuoted]: 4, + [ApiNotificationCause.DropReplied]: 5, + [ApiNotificationCause.DropVoted]: 6, + [ApiNotificationCause.DropBoosted]: 7, + [ApiNotificationCause.DropReacted]: 8, + [ApiNotificationCause.WaveCreated]: 9, + [ApiNotificationCause.AllDrops]: 10, + [ApiNotificationCause.PriorityAlert]: 11, }; const compareNotificationCause = ( @@ -51,7 +53,7 @@ export default function Notifications({ const activeFilterKey = useMemo( () => - activeFilter?.cause + activeFilter?.cause?.length ? [...activeFilter.cause].sort(compareNotificationCause).join("|") : "notifications-filter-all", [activeFilter] diff --git a/components/header/AppUserConnect.tsx b/components/header/AppUserConnect.tsx index 262c5cae5f..d8407c8cdc 100644 --- a/components/header/AppUserConnect.tsx +++ b/components/header/AppUserConnect.tsx @@ -1,8 +1,11 @@ import { - ArrowRightEndOnRectangleIcon, - ArrowsRightLeftIcon, + ArrowRightEndOnRectangleIcon, + ArrowsRightLeftIcon, + Cog6ToothIcon, } from "@heroicons/react/24/outline"; +import { useState } from "react"; import { useSeizeConnectContext } from "../auth/SeizeConnectContext"; +import PushNotificationSettings from "./PushNotificationSettings"; import HeaderQRScanner from "./share/HeaderQRScanner"; export default function AppUserConnect({ @@ -12,6 +15,7 @@ export default function AppUserConnect({ }) { const { address, seizeConnect, seizeDisconnectAndLogout } = useSeizeConnectContext(); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const qrScanner = ; @@ -22,21 +26,31 @@ export default function AppUserConnect({ onNavigate(); }} type="button" - className="tw-whitespace-nowrap tw-flex tw-w-full tw-items-center tw-justify-center tw-cursor-pointer tw-bg-primary-500 tw-px-4 tw-py-2.5 tw-text-sm tw-leading-6 tw-rounded-lg tw-font-semibold tw-text-white tw-border-0 tw-ring-1 tw-ring-inset tw-ring-primary-500 hover:tw-ring-primary-600 placeholder:tw-text-iron-300 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset tw-shadow-sm hover:tw-bg-primary-600 tw-transition tw-duration-300 tw-ease-out"> + className="tw-flex tw-w-full tw-cursor-pointer tw-items-center tw-justify-center tw-whitespace-nowrap tw-rounded-lg tw-border-0 tw-bg-primary-500 tw-px-4 tw-py-2.5 tw-text-sm tw-font-semibold tw-leading-6 tw-text-white tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-primary-500 tw-transition tw-duration-300 tw-ease-out placeholder:tw-text-iron-300 hover:tw-bg-primary-600 hover:tw-ring-primary-600 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset" + > Connect ); const connectedButtons = ( <> + + setIsSettingsOpen(false)} + /> ); diff --git a/components/header/PushNotificationSettings.tsx b/components/header/PushNotificationSettings.tsx new file mode 100644 index 0000000000..21035d60de --- /dev/null +++ b/components/header/PushNotificationSettings.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useAuth } from "@/components/auth/Auth"; +import MobileWrapperDialog from "@/components/mobile-wrapper-dialog/MobileWrapperDialog"; +import { getStableDeviceId } from "@/components/notifications/stable-device-id"; +import type { ApiPushNotificationSettings } from "@/generated/models/ApiPushNotificationSettings"; +import { commonApiFetch, commonApiPut } from "@/services/api/common-api"; +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Toggle from "react-toggle"; + +interface PushNotificationSettingsProps { + readonly isOpen: boolean; + readonly onClose: () => void; +} + +const SETTINGS_LABELS: Record = { + identity_rep: "Identity - REP", + identity_nic: "Identity - NIC", + identity_subscribed: "New Follows", + identity_mentioned: "Mentions", + drop_quoted: "Drops - Quoted", + drop_replied: "Drops - Replied", + drop_voted: "Drops - Voted", + drop_reacted: "Drops - Reacted", + drop_boosted: "Drops - Boosted", + wave_created: "Wave Invites", +}; + +const DEFAULT_SETTINGS: ApiPushNotificationSettings = { + identity_subscribed: true, + identity_mentioned: true, + identity_rep: true, + identity_nic: true, + drop_quoted: true, + drop_replied: true, + drop_voted: true, + drop_reacted: true, + drop_boosted: true, + wave_created: true, +}; + +export default function PushNotificationSettings({ + isOpen, + onClose, +}: PushNotificationSettingsProps) { + const { setToast } = useAuth(); + const [deviceId, setDeviceId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [originalSettings, setOriginalSettings] = + useState(null); + const [currentSettings, setCurrentSettings] = + useState(null); + const scrollRef = useRef(null); + const [canScrollUp, setCanScrollUp] = useState(false); + const [canScrollDown, setCanScrollDown] = useState(false); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + const updateScrollState = () => { + setCanScrollUp(el.scrollTop > 0); + setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1); + }; + + updateScrollState(); + el.addEventListener("scroll", updateScrollState); + const resizeObserver = new ResizeObserver(updateScrollState); + resizeObserver.observe(el); + + return () => { + el.removeEventListener("scroll", updateScrollState); + resizeObserver.disconnect(); + }; + }, [currentSettings]); + + // Get device ID and fetch settings when opened + useEffect(() => { + if (!isOpen) return; + + const loadSettings = async () => { + setIsLoading(true); + try { + const id = await getStableDeviceId(); + setDeviceId(id); + + try { + const settings = await commonApiFetch({ + endpoint: `push-notifications/settings/${id}`, + }); + setOriginalSettings(settings); + setCurrentSettings(settings); + } catch (error: unknown) { + const status = + (error as { status?: number })?.status ?? + (error as { response?: { status?: number } })?.response?.status; + if (status === 404) { + setOriginalSettings(DEFAULT_SETTINGS); + setCurrentSettings(DEFAULT_SETTINGS); + } else { + console.error("Error fetching push notification settings:", error); + setToast({ + message: "Failed to load notification settings", + type: "error", + }); + } + } + } catch (error) { + console.error("Error loading push notification settings:", error); + } finally { + setIsLoading(false); + } + }; + + loadSettings(); + }, [isOpen]); + + const hasChanges = + currentSettings && + originalSettings && + Object.keys(originalSettings).some( + (key) => + originalSettings[key as keyof ApiPushNotificationSettings] !== + currentSettings[key as keyof ApiPushNotificationSettings] + ); + + const updateSetting = useCallback( + (key: keyof ApiPushNotificationSettings, value: boolean) => { + setCurrentSettings((prev) => (prev ? { ...prev, [key]: value } : prev)); + }, + [] + ); + + const { mutateAsync: saveSettings, isPending: isSaving } = useMutation({ + mutationFn: async () => { + if (!deviceId || !currentSettings) return; + return await commonApiPut< + ApiPushNotificationSettings, + ApiPushNotificationSettings + >({ + endpoint: `push-notifications/settings/${deviceId}`, + body: currentSettings, + }); + }, + onSuccess: () => { + setOriginalSettings(currentSettings); + setToast({ + message: "Notification settings updated", + type: "success", + }); + onClose(); + }, + onError: (error: unknown) => { + console.error("Error saving push notification settings:", error); + setToast({ + message: "Failed to save notification settings", + type: "error", + }); + }, + }); + + const handleSave = useCallback(async () => { + await saveSettings(); + }, [saveSettings]); + + const settingKeys = Object.keys(SETTINGS_LABELS) as Array< + keyof ApiPushNotificationSettings + >; + + return ( + +
+ {isLoading && ( +
+
+
+ )} + {!isLoading && !currentSettings && ( +
+

+ Unable to load notification settings. +

+
+ )} + {!isLoading && currentSettings && ( + <> +

+ Choose which notifications you want to receive on this device. +

+ +
+ {canScrollUp && ( +
+ )} +
+
+ {settingKeys.map((key) => ( +
+ + updateSetting(key, e.target.checked)} + /> +
+ ))} +
+
+ {canScrollDown && ( +
+ )} +
+ +
+ {hasChanges && ( +

+ Tap below to save your changes +

+ )} + +
+ + )} +
+ + ); +} diff --git a/components/nft-image/renderers/NFTHTMLRenderer.tsx b/components/nft-image/renderers/NFTHTMLRenderer.tsx index b14c6a9052..da9ecf58e1 100644 --- a/components/nft-image/renderers/NFTHTMLRenderer.tsx +++ b/components/nft-image/renderers/NFTHTMLRenderer.tsx @@ -23,7 +23,8 @@ export default function NFTHTMLRenderer(props: Readonly) { return ( + className={`${styles["nftAnimation"]} ${props.heightStyle} ${props.imageStyle} ${props.bgStyle} d-flex justify-content-center align-items-center`} + > {props.showBalance && ( `/${path}`, - profile: ({ handle }: { handle: string }) => `/${handle}`, + profile: ({ handle, subroute }: { handle: string; subroute?: string }) => { + if (!subroute) return `/${handle}`; + const validTab = getUserPageTabByRoute(subroute); + if (!validTab) return `/${handle}`; + return `/${handle}/${validTab.route}`; + }, "the-memes": ({ id }: { id: string }) => `/the-memes/${id}`, "6529-gradient": ({ id }: { id: string }) => `/6529-gradient/${id}`, "meme-lab": ({ id }: { id: string }) => `/meme-lab/${id}`, @@ -94,6 +100,7 @@ interface NotificationData { profile_id?: string | undefined; path?: string | undefined; handle?: string | undefined; + subroute?: string | undefined; id?: string | undefined; wave_id?: string | undefined; drop_id?: string | undefined; diff --git a/components/providers/CapacitorSetup.tsx b/components/providers/CapacitorSetup.tsx index c89c02e5be..c8139f1a77 100644 --- a/components/providers/CapacitorSetup.tsx +++ b/components/providers/CapacitorSetup.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef } from "react"; import useCapacitor from "@/hooks/useCapacitor"; +import { useEffect, useRef } from "react"; export default function CapacitorSetup() { const { isCapacitor } = useCapacitor(); @@ -44,5 +44,6 @@ export default function CapacitorSetup() { } }; }, [isCapacitor]); + return null; } diff --git a/components/providers/LayoutWrapper.tsx b/components/providers/LayoutWrapper.tsx index a92624e20f..80a3be8d2f 100644 --- a/components/providers/LayoutWrapper.tsx +++ b/components/providers/LayoutWrapper.tsx @@ -9,8 +9,10 @@ import { SIDEBAR_MOBILE_BREAKPOINT } from "@/constants/sidebar"; import useIsMobileScreen from "@/hooks/isMobileScreen"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import { usePathname } from "next/navigation"; +import { useGlobalRefresh } from "@/contexts/RefreshContext"; import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { ErrorBoundary } from "react-error-boundary"; +import PullToRefresh from "./PullToRefresh"; export default function LayoutWrapper({ children, @@ -18,6 +20,7 @@ export default function LayoutWrapper({ readonly children: ReactNode; }) { const { isApp, hasTouchScreen } = useDeviceInfo(); + const { refreshKey } = useGlobalRefresh(); const isSmallScreen = useIsMobileScreen(); const [isTouchTabletViewport, setIsTouchTabletViewport] = useState(() => { if (globalThis.window === undefined) { @@ -85,9 +88,11 @@ export default function LayoutWrapper({ return ( + {isApp && } {children} diff --git a/components/providers/Providers.tsx b/components/providers/Providers.tsx index 45c450b610..43c754f224 100644 --- a/components/providers/Providers.tsx +++ b/components/providers/Providers.tsx @@ -10,6 +10,7 @@ import NewVersionToast from "@/components/utils/NewVersionToast"; import { EmojiProvider } from "@/contexts/EmojiContext"; import { HeaderProvider } from "@/contexts/HeaderContext"; import { NavigationHistoryProvider } from "@/contexts/NavigationHistoryContext"; +import { RefreshProvider } from "@/contexts/RefreshContext"; import { ScrollPositionProvider } from "@/contexts/ScrollPositionContext"; import { SeizeSettingsProvider } from "@/contexts/SeizeSettingsContext"; import { TitleProvider } from "@/contexts/TitleContext"; @@ -35,42 +36,44 @@ export default function Providers({ - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + + + diff --git a/components/providers/PullToRefresh.tsx b/components/providers/PullToRefresh.tsx new file mode 100644 index 0000000000..481f31a1de --- /dev/null +++ b/components/providers/PullToRefresh.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useGlobalRefresh } from "@/contexts/RefreshContext"; +import useCapacitor from "@/hooks/useCapacitor"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { ReactQueryWrapperContext } from "../react-query-wrapper/ReactQueryWrapper"; + +const PULL_THRESHOLD = 80; +const PULL_MAX = 140; +const INDICATOR_SIZE = 36; + +export default function PullToRefresh() { + const { isCapacitor } = useCapacitor(); + const { invalidateAll } = useContext(ReactQueryWrapperContext); + const { globalRefresh } = useGlobalRefresh(); + const [pullDistance, setPullDistance] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + const touchStartY = useRef(0); + const isPulling = useRef(false); + const contentRef = useRef(null); + const refreshTimeoutRef = useRef | null>(null); + const pullDistanceRef = useRef(0); + const isRefreshingRef = useRef(false); + + const isAtTop = useCallback(() => { + return window.scrollY <= 0; + }, []); + + const getScrollableParent = useCallback( + (element: HTMLElement | null): HTMLElement | null => { + if (!element) return null; + const style = globalThis.getComputedStyle(element); + const overflowY = style.overflowY; + if (overflowY === "scroll" || overflowY === "auto") { + if (element.scrollHeight > element.clientHeight) { + return element; + } + } + return getScrollableParent(element.parentElement); + }, + [] + ); + + const resetContentStyles = useCallback(() => { + if (!contentRef.current) return; + contentRef.current.style.transform = ""; + contentRef.current.style.transition = ""; + }, []); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (isRefreshingRef.current) return; + + const target = e.target as HTMLElement; + const scrollableParent = getScrollableParent(target); + + if (scrollableParent && scrollableParent.scrollTop > 0) { + return; + } + + if (!isAtTop()) return; + const touch = e.touches[0]; + if (!touch) return; + touchStartY.current = touch.clientY; + + isPulling.current = true; + contentRef.current = document.body; + }, + [isAtTop, getScrollableParent] + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (!isPulling.current || isRefreshingRef.current) return; + + const target = e.target as HTMLElement; + const scrollableParent = getScrollableParent(target); + const isScrolledInParent = + scrollableParent && scrollableParent.scrollTop > 0; + + if (isScrolledInParent || !isAtTop()) { + pullDistanceRef.current = 0; + setPullDistance(0); + resetContentStyles(); + return; + } + + const touch = e.touches[0]; + if (!touch) return; + + const diff = touch.clientY - touchStartY.current; + + if (diff <= 0) { + pullDistanceRef.current = 0; + setPullDistance(0); + resetContentStyles(); + return; + } + + const resistance = 0.5; + const distance = Math.min(diff * resistance, PULL_MAX); + pullDistanceRef.current = distance; + setPullDistance(distance); + + if (contentRef.current) { + contentRef.current.style.transform = `translateY(${distance}px)`; + contentRef.current.style.transition = "none"; + } + + if (distance > 10) { + e.preventDefault(); + } + }, + [isAtTop, getScrollableParent, resetContentStyles] + ); + + const handleTouchEnd = useCallback(() => { + if (!isPulling.current) return; + isPulling.current = false; + + if (pullDistanceRef.current >= PULL_THRESHOLD && !isRefreshingRef.current) { + isRefreshingRef.current = true; + setIsRefreshing(true); + + if (contentRef.current) { + contentRef.current.style.transform = `translateY(${INDICATOR_SIZE + 20}px)`; + contentRef.current.style.transition = "transform 0.3s ease-out"; + } + pullDistanceRef.current = INDICATOR_SIZE + 20; + setPullDistance(INDICATOR_SIZE + 20); + + invalidateAll(); + globalRefresh(); + + refreshTimeoutRef.current = setTimeout(() => { + isRefreshingRef.current = false; + setIsRefreshing(false); + pullDistanceRef.current = 0; + setPullDistance(0); + if (contentRef.current) { + contentRef.current.style.transform = ""; + contentRef.current.style.transition = "transform 0.3s ease-out"; + } + refreshTimeoutRef.current = null; + }, 1000); + } else { + pullDistanceRef.current = 0; + setPullDistance(0); + if (contentRef.current) { + contentRef.current.style.transform = ""; + contentRef.current.style.transition = "transform 0.3s ease-out"; + } + } + }, [invalidateAll, globalRefresh]); + + const handleTouchCancel = useCallback(() => { + isPulling.current = false; + + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + refreshTimeoutRef.current = null; + } + + isRefreshingRef.current = false; + setIsRefreshing(false); + pullDistanceRef.current = 0; + setPullDistance(0); + + if (contentRef.current) { + contentRef.current.style.transform = ""; + contentRef.current.style.transition = ""; + } + }, []); + + useEffect(() => { + if (!isCapacitor) return; + + document.addEventListener("touchstart", handleTouchStart, { + passive: true, + }); + document.addEventListener("touchmove", handleTouchMove, { passive: false }); + document.addEventListener("touchend", handleTouchEnd, { passive: true }); + document.addEventListener("touchcancel", handleTouchCancel, { + passive: true, + }); + + return () => { + document.removeEventListener("touchstart", handleTouchStart); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + document.removeEventListener("touchcancel", handleTouchCancel); + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + refreshTimeoutRef.current = null; + } + if (contentRef.current) { + contentRef.current.style.transform = ""; + contentRef.current.style.transition = ""; + } + }; + }, [ + isCapacitor, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + handleTouchCancel, + ]); + + if (!isCapacitor || pullDistance === 0) return null; + + const progress = Math.min(pullDistance / PULL_THRESHOLD, 1); + const shouldTrigger = pullDistance >= PULL_THRESHOLD; + const rotation = progress * 180; + + return ( +
+
+ {isRefreshing ? ( + + + + ) : ( + + + + )} +
+
+ ); +} diff --git a/components/user/user-page-header/UserPageHeaderClient.tsx b/components/user/user-page-header/UserPageHeaderClient.tsx index 61ca453026..5cda0f356c 100644 --- a/components/user/user-page-header/UserPageHeaderClient.tsx +++ b/components/user/user-page-header/UserPageHeaderClient.tsx @@ -50,7 +50,8 @@ export default function UserPageHeaderClient({ const params = useParams(); const router = useRouter(); const { isApp } = useDeviceInfo(); - const routeHandleOrWallet = params?.["user"]?.toString().toLowerCase() ?? null; + const routeHandleOrWallet = + params?.["user"]?.toString().toLowerCase() ?? null; const normalizedHandleOrWallet = routeHandleOrWallet ?? handleOrWallet.toLowerCase(); @@ -162,10 +163,10 @@ export default function UserPageHeaderClient({ defaultBanner2={defaultBanner2} canEdit={canEdit} /> -
+
-
+
void; + refreshKey: number; +}; + +const Ctx = createContext(null); + +export function RefreshProvider({ + children, +}: { + readonly children: React.ReactNode; +}) { + const [refreshKey, setRefreshKey] = useState(0); + + const globalRefresh = useCallback(() => { + setRefreshKey((prev) => prev + 1); + }, []); + + const value = useMemo( + () => ({ globalRefresh, refreshKey }), + [globalRefresh, refreshKey] + ); + + return {children}; +} + +export function useGlobalRefresh() { + const ctx = useContext(Ctx); + if (!ctx) { + throw new Error("useGlobalRefresh must be used under "); + } + return ctx; +} diff --git a/generated/models/ApiMintMetrics.ts b/generated/models/ApiMintMetrics.ts index 7f501c207f..a00de66710 100644 --- a/generated/models/ApiMintMetrics.ts +++ b/generated/models/ApiMintMetrics.ts @@ -18,6 +18,8 @@ export class ApiMintMetrics { 'mint_time': number; 'subscriptions': number; 'mints': number; + 'edition_size': number | null; + 'unminted': number; static readonly discriminator: string | undefined = undefined; @@ -47,6 +49,18 @@ export class ApiMintMetrics { "baseName": "mints", "type": "number", "format": "int64" + }, + { + "name": "edition_size", + "baseName": "edition_size", + "type": "number", + "format": "int64" + }, + { + "name": "unminted", + "baseName": "unminted", + "type": "number", + "format": "int64" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ApiNotificationCause.ts b/generated/models/ApiNotificationCause.ts index c587915cf2..7eeca9fc74 100644 --- a/generated/models/ApiNotificationCause.ts +++ b/generated/models/ApiNotificationCause.ts @@ -16,6 +16,8 @@ import { HttpFile } from '../http/http'; export enum ApiNotificationCause { IdentitySubscribed = 'IDENTITY_SUBSCRIBED', IdentityMentioned = 'IDENTITY_MENTIONED', + IdentityRep = 'IDENTITY_REP', + IdentityNic = 'IDENTITY_NIC', DropQuoted = 'DROP_QUOTED', DropReplied = 'DROP_REPLIED', DropVoted = 'DROP_VOTED', diff --git a/generated/models/ApiPushNotificationDevice.ts b/generated/models/ApiPushNotificationDevice.ts new file mode 100644 index 0000000000..46e8bc1a33 --- /dev/null +++ b/generated/models/ApiPushNotificationDevice.ts @@ -0,0 +1,58 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export class ApiPushNotificationDevice { + 'device_id': string; + 'platform'?: string | null; + 'created_at': Date; + 'updated_at': Date; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "device_id", + "baseName": "device_id", + "type": "string", + "format": "" + }, + { + "name": "platform", + "baseName": "platform", + "type": "string", + "format": "" + }, + { + "name": "created_at", + "baseName": "created_at", + "type": "Date", + "format": "date-time" + }, + { + "name": "updated_at", + "baseName": "updated_at", + "type": "Date", + "format": "date-time" + } ]; + + static getAttributeTypeMap() { + return ApiPushNotificationDevice.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ApiPushNotificationSettings.ts b/generated/models/ApiPushNotificationSettings.ts new file mode 100644 index 0000000000..37139eb986 --- /dev/null +++ b/generated/models/ApiPushNotificationSettings.ts @@ -0,0 +1,130 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export class ApiPushNotificationSettings { + /** + * Notify when someone follows you + */ + 'identity_subscribed': boolean; + /** + * Notify when someone mentions you + */ + 'identity_mentioned': boolean; + /** + * Notify when someone gives you REP + */ + 'identity_rep': boolean; + /** + * Notify when someone gives you NIC + */ + 'identity_nic': boolean; + /** + * Notify when someone quotes your drop + */ + 'drop_quoted': boolean; + /** + * Notify when someone replies to your drop + */ + 'drop_replied': boolean; + /** + * Notify when someone votes on your drop + */ + 'drop_voted': boolean; + /** + * Notify when someone reacts to your drop + */ + 'drop_reacted': boolean; + /** + * Notify when someone boosts your drop + */ + 'drop_boosted': boolean; + /** + * Notify when you are invited to a wave + */ + 'wave_created': boolean; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "identity_subscribed", + "baseName": "identity_subscribed", + "type": "boolean", + "format": "" + }, + { + "name": "identity_mentioned", + "baseName": "identity_mentioned", + "type": "boolean", + "format": "" + }, + { + "name": "identity_rep", + "baseName": "identity_rep", + "type": "boolean", + "format": "" + }, + { + "name": "identity_nic", + "baseName": "identity_nic", + "type": "boolean", + "format": "" + }, + { + "name": "drop_quoted", + "baseName": "drop_quoted", + "type": "boolean", + "format": "" + }, + { + "name": "drop_replied", + "baseName": "drop_replied", + "type": "boolean", + "format": "" + }, + { + "name": "drop_voted", + "baseName": "drop_voted", + "type": "boolean", + "format": "" + }, + { + "name": "drop_reacted", + "baseName": "drop_reacted", + "type": "boolean", + "format": "" + }, + { + "name": "drop_boosted", + "baseName": "drop_boosted", + "type": "boolean", + "format": "" + }, + { + "name": "wave_created", + "baseName": "wave_created", + "type": "boolean", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiPushNotificationSettings.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ApiPushNotificationSettingsUpdate.ts b/generated/models/ApiPushNotificationSettingsUpdate.ts new file mode 100644 index 0000000000..fd9f66ef37 --- /dev/null +++ b/generated/models/ApiPushNotificationSettingsUpdate.ts @@ -0,0 +1,100 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export class ApiPushNotificationSettingsUpdate { + 'identity_subscribed'?: boolean; + 'identity_mentioned'?: boolean; + 'identity_rep'?: boolean; + 'identity_nic'?: boolean; + 'drop_quoted'?: boolean; + 'drop_replied'?: boolean; + 'drop_voted'?: boolean; + 'drop_reacted'?: boolean; + 'drop_boosted'?: boolean; + 'wave_created'?: boolean; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "identity_subscribed", + "baseName": "identity_subscribed", + "type": "boolean", + "format": "" + }, + { + "name": "identity_mentioned", + "baseName": "identity_mentioned", + "type": "boolean", + "format": "" + }, + { + "name": "identity_rep", + "baseName": "identity_rep", + "type": "boolean", + "format": "" + }, + { + "name": "identity_nic", + "baseName": "identity_nic", + "type": "boolean", + "format": "" + }, + { + "name": "drop_quoted", + "baseName": "drop_quoted", + "type": "boolean", + "format": "" + }, + { + "name": "drop_replied", + "baseName": "drop_replied", + "type": "boolean", + "format": "" + }, + { + "name": "drop_voted", + "baseName": "drop_voted", + "type": "boolean", + "format": "" + }, + { + "name": "drop_reacted", + "baseName": "drop_reacted", + "type": "boolean", + "format": "" + }, + { + "name": "drop_boosted", + "baseName": "drop_boosted", + "type": "boolean", + "format": "" + }, + { + "name": "wave_created", + "baseName": "wave_created", + "type": "boolean", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiPushNotificationSettingsUpdate.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ApiWave.ts b/generated/models/ApiWave.ts index b8f9b04585..e634808e19 100644 --- a/generated/models/ApiWave.ts +++ b/generated/models/ApiWave.ts @@ -18,7 +18,6 @@ import { ApiWaveConfig } from '../models/ApiWaveConfig'; import { ApiWaveContributorOverview } from '../models/ApiWaveContributorOverview'; import { ApiWaveDecisionPause } from '../models/ApiWaveDecisionPause'; import { ApiWaveMetrics } from '../models/ApiWaveMetrics'; -import { ApiWaveOutcomeOld } from '../models/ApiWaveOutcomeOld'; import { ApiWaveParticipationConfig } from '../models/ApiWaveParticipationConfig'; import { ApiWaveSubscriptionTargetAction } from '../models/ApiWaveSubscriptionTargetAction'; import { ApiWaveVisibilityConfig } from '../models/ApiWaveVisibilityConfig'; @@ -50,7 +49,6 @@ export class ApiWave { 'participation': ApiWaveParticipationConfig; 'chat': ApiWaveChatConfig; 'wave': ApiWaveConfig; - 'outcomes': Array; 'contributors_overview': Array; 'subscribed_actions': Array; 'metrics': ApiWaveMetrics; @@ -134,12 +132,6 @@ export class ApiWave { "type": "ApiWaveConfig", "format": "" }, - { - "name": "outcomes", - "baseName": "outcomes", - "type": "Array", - "format": "" - }, { "name": "contributors_overview", "baseName": "contributors_overview", diff --git a/generated/models/ApiWaveOutcomeOld.ts b/generated/models/ApiWaveOutcomeOld.ts deleted file mode 100644 index 6bdd9f4c97..0000000000 --- a/generated/models/ApiWaveOutcomeOld.ts +++ /dev/null @@ -1,92 +0,0 @@ -// @ts-nocheck -/** - * 6529.io API - * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. - * - * OpenAPI spec version: 1.0.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { ApiWaveOutcomeCredit } from '../models/ApiWaveOutcomeCredit'; -import { ApiWaveOutcomeDistributionItem } from '../models/ApiWaveOutcomeDistributionItem'; -import { ApiWaveOutcomeSubType } from '../models/ApiWaveOutcomeSubType'; -import { ApiWaveOutcomeType } from '../models/ApiWaveOutcomeType'; -import { HttpFile } from '../http/http'; - -export class ApiWaveOutcomeOld { - 'type': ApiWaveOutcomeType; - 'subtype'?: ApiWaveOutcomeSubType; - 'description': string; - 'credit'?: ApiWaveOutcomeCredit; - 'rep_category'?: string; - 'amount'?: number; - 'distribution'?: Array; - 'index': number; - - static readonly discriminator: string | undefined = undefined; - - static readonly mapping: {[index: string]: string} | undefined = undefined; - - static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ - { - "name": "type", - "baseName": "type", - "type": "ApiWaveOutcomeType", - "format": "" - }, - { - "name": "subtype", - "baseName": "subtype", - "type": "ApiWaveOutcomeSubType", - "format": "" - }, - { - "name": "description", - "baseName": "description", - "type": "string", - "format": "" - }, - { - "name": "credit", - "baseName": "credit", - "type": "ApiWaveOutcomeCredit", - "format": "" - }, - { - "name": "rep_category", - "baseName": "rep_category", - "type": "string", - "format": "" - }, - { - "name": "amount", - "baseName": "amount", - "type": "number", - "format": "int64" - }, - { - "name": "distribution", - "baseName": "distribution", - "type": "Array", - "format": "" - }, - { - "name": "index", - "baseName": "index", - "type": "number", - "format": "int64" - } ]; - - static getAttributeTypeMap() { - return ApiWaveOutcomeOld.attributeTypeMap; - } - - public constructor() { - } -} - - diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index 58e33c9797..1415ba9d91 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -130,6 +130,9 @@ export * from '../models/ApiProfileMin'; export * from '../models/ApiProfileProxy'; export * from '../models/ApiProfileProxyAction'; export * from '../models/ApiProfileProxyActionType'; +export * from '../models/ApiPushNotificationDevice'; +export * from '../models/ApiPushNotificationSettings'; +export * from '../models/ApiPushNotificationSettingsUpdate'; export * from '../models/ApiQuotedDrop'; export * from '../models/ApiQuotedDropResponse'; export * from '../models/ApiRateMatter'; @@ -180,7 +183,6 @@ export * from '../models/ApiWaveOutcome'; export * from '../models/ApiWaveOutcomeCredit'; export * from '../models/ApiWaveOutcomeDistributionItem'; export * from '../models/ApiWaveOutcomeDistributionItemsPage'; -export * from '../models/ApiWaveOutcomeOld'; export * from '../models/ApiWaveOutcomeSubType'; export * from '../models/ApiWaveOutcomeType'; export * from '../models/ApiWaveOutcomesPage'; @@ -380,6 +382,9 @@ import { ApiProfileMin } from '../models/ApiProfileMin'; import { ApiProfileProxy } from '../models/ApiProfileProxy'; import { ApiProfileProxyAction } from '../models/ApiProfileProxyAction'; import { ApiProfileProxyActionType } from '../models/ApiProfileProxyActionType'; +import { ApiPushNotificationDevice } from '../models/ApiPushNotificationDevice'; +import { ApiPushNotificationSettings } from '../models/ApiPushNotificationSettings'; +import { ApiPushNotificationSettingsUpdate } from '../models/ApiPushNotificationSettingsUpdate'; import { ApiQuotedDrop } from '../models/ApiQuotedDrop'; import { ApiQuotedDropResponse } from '../models/ApiQuotedDropResponse'; import { ApiRateMatter } from '../models/ApiRateMatter'; @@ -430,7 +435,6 @@ import { ApiWaveOutcome } from '../models/ApiWaveOutcome'; import { ApiWaveOutcomeCredit } from '../models/ApiWaveOutcomeCredit'; import { ApiWaveOutcomeDistributionItem } from '../models/ApiWaveOutcomeDistributionItem'; import { ApiWaveOutcomeDistributionItemsPage } from '../models/ApiWaveOutcomeDistributionItemsPage'; -import { ApiWaveOutcomeOld } from '../models/ApiWaveOutcomeOld'; import { ApiWaveOutcomeSubType } from '../models/ApiWaveOutcomeSubType'; import { ApiWaveOutcomeType } from '../models/ApiWaveOutcomeType'; import { ApiWaveOutcomesPage } from '../models/ApiWaveOutcomesPage'; @@ -663,6 +667,9 @@ let typeMap: {[index: string]: any} = { "ApiProfileMin": ApiProfileMin, "ApiProfileProxy": ApiProfileProxy, "ApiProfileProxyAction": ApiProfileProxyAction, + "ApiPushNotificationDevice": ApiPushNotificationDevice, + "ApiPushNotificationSettings": ApiPushNotificationSettings, + "ApiPushNotificationSettingsUpdate": ApiPushNotificationSettingsUpdate, "ApiQuotedDrop": ApiQuotedDrop, "ApiQuotedDropResponse": ApiQuotedDropResponse, "ApiRatingWithProfileInfoAndLevel": ApiRatingWithProfileInfoAndLevel, @@ -708,7 +715,6 @@ let typeMap: {[index: string]: any} = { "ApiWaveOutcome": ApiWaveOutcome, "ApiWaveOutcomeDistributionItem": ApiWaveOutcomeDistributionItem, "ApiWaveOutcomeDistributionItemsPage": ApiWaveOutcomeDistributionItemsPage, - "ApiWaveOutcomeOld": ApiWaveOutcomeOld, "ApiWaveOutcomesPage": ApiWaveOutcomesPage, "ApiWaveParticipationConfig": ApiWaveParticipationConfig, "ApiWaveRequiredMetadata": ApiWaveRequiredMetadata, diff --git a/hooks/useNotificationsQuery.tsx b/hooks/useNotificationsQuery.tsx index e3756463a1..443ffb9f7d 100644 --- a/hooks/useNotificationsQuery.tsx +++ b/hooks/useNotificationsQuery.tsx @@ -1,13 +1,11 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { commonApiFetch } from "@/services/api/common-api"; -import type { - TypedNotificationsResponse, -} from "@/types/feed.types"; -import type { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; +import { commonApiFetch } from "@/services/api/common-api"; +import type { TypedNotificationsResponse } from "@/types/feed.types"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo } from "react"; interface UseNotificationsQueryProps { /** @@ -33,7 +31,7 @@ interface UseNotificationsQueryProps { readonly limit?: string | undefined; /** - * The cause of the notifications to fetch. + * The cause of the notifications to fetch (include filter). */ readonly cause?: ApiNotificationCause[] | null | undefined; } @@ -49,7 +47,17 @@ const getIdentityNotificationsQueryKey = ( identity: string | null | undefined, limit: string, cause: ApiNotificationCause[] | null -) => [QueryKey.IDENTITY_NOTIFICATIONS, { identity, limit, cause }] as const; +) => + [ + QueryKey.IDENTITY_NOTIFICATIONS, + { + identity, + limit, + cause: cause?.length + ? [...cause].sort((a, b) => a.localeCompare(b)).join(",") + : null, + }, + ] as const; const fetchNotifications = async ({ limit, @@ -117,7 +125,8 @@ export function useNotificationsQuery({ (error as any)?.response?.status ?? (error as any)?.cause?.status; if (status === 401) return false; - if (typeof error === "string" && /unauthorized/i.test(error)) return false; + if (typeof error === "string" && /unauthorized/i.test(error)) + return false; if (error instanceof Error && /unauthorized/i.test(error.message)) { return false; } @@ -130,9 +139,9 @@ export function useNotificationsQuery({ return []; } - const data = ( - query.data.pages as TypedNotificationsResponse[] - ).flatMap((page) => page.notifications); + const data = (query.data.pages as TypedNotificationsResponse[]).flatMap( + (page) => page.notifications + ); return reverse ? [...data].reverse() : data; }, [query.data, reverse]); @@ -166,7 +175,11 @@ export function usePrefetchNotifications() { return; } queryClient.prefetchInfiniteQuery({ - queryKey: getIdentityNotificationsQueryKey(identity, limit, cause), + queryKey: getIdentityNotificationsQueryKey( + identity, + limit, + cause?.length ? cause : null + ), queryFn: ({ pageParam, signal, diff --git a/openapi.yaml b/openapi.yaml index 8af135b1f6..d3c51d5451 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -814,6 +814,12 @@ paths: required: false schema: $ref: "#/components/schemas/ApiDropType" + - name: ids + in: query + description: Comma-separated list of drop IDs to fetch + required: false + schema: + type: string responses: "200": description: successful operation @@ -1908,6 +1914,24 @@ paths: schema: type: number format: int64 + - name: cause + in: query + description: Comma-separated list of notification causes to include + required: false + schema: + type: string + - name: cause_exclude + in: query + description: Comma-separated list of notification causes to exclude + required: false + schema: + type: string + - name: unread_only + in: query + description: Only return unread notifications + required: false + schema: + type: boolean responses: "200": description: successful operation @@ -2738,6 +2762,100 @@ paths: description: Invalid request "401": description: Unauthorized + /push-notifications/devices: + get: + tags: + - Push Notifications + summary: Get all registered devices for the authenticated user + operationId: getDevices + responses: + "200": + description: List of registered devices + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApiPushNotificationDevice" + "401": + description: Unauthorized + "403": + description: Forbidden - profile required + /push-notifications/devices/{device_id}: + delete: + tags: + - Push Notifications + summary: Delete a registered device + operationId: deleteDevice + parameters: + - name: device_id + in: path + required: true + schema: + type: string + description: The device ID to delete + responses: + "204": + description: Device deleted successfully + "401": + description: Unauthorized + "403": + description: Forbidden - profile required + /push-notifications/settings/{device_id}: + get: + tags: + - Push Notifications + summary: Get push notification settings for a device + operationId: getPushNotificationSettings + parameters: + - name: device_id + in: path + required: true + schema: + type: string + description: The device ID to get settings for + responses: + "200": + description: Push notification settings + content: + application/json: + schema: + $ref: "#/components/schemas/ApiPushNotificationSettings" + "401": + description: Unauthorized + "403": + description: Forbidden - profile required + put: + tags: + - Push Notifications + summary: Update push notification settings for a device + operationId: updatePushNotificationSettings + parameters: + - name: device_id + in: path + required: true + schema: + type: string + description: The device ID to update settings for + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApiPushNotificationSettingsUpdate" + responses: + "200": + description: Updated push notification settings + content: + application/json: + schema: + $ref: "#/components/schemas/ApiPushNotificationSettings" + "400": + description: Invalid request + "401": + description: Unauthorized + "403": + description: Forbidden - profile required /bulk-rep: post: tags: @@ -7429,6 +7547,8 @@ components: - mint_time - subscriptions - mints + - edition_size + - unminted properties: card: type: number @@ -7442,6 +7562,13 @@ components: mints: type: number format: int64 + edition_size: + type: number + format: int64 + nullable: true + unminted: + type: number + format: int64 ApiMintMetricsPage: type: object required: @@ -7702,6 +7829,8 @@ components: enum: - IDENTITY_SUBSCRIBED - IDENTITY_MENTIONED + - IDENTITY_REP + - IDENTITY_NIC - DROP_QUOTED - DROP_REPLIED - DROP_VOTED @@ -8147,6 +8276,91 @@ components: - READ_WAVE - CREATE_DROP_TO_WAVE - RATE_WAVE_DROP + ApiPushNotificationDevice: + type: object + required: + - device_id + - created_at + - updated_at + properties: + device_id: + type: string + platform: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ApiPushNotificationSettings: + type: object + required: + - identity_subscribed + - identity_mentioned + - identity_rep + - identity_nic + - drop_quoted + - drop_replied + - drop_voted + - drop_reacted + - drop_boosted + - wave_created + properties: + identity_subscribed: + type: boolean + description: Notify when someone follows you + identity_mentioned: + type: boolean + description: Notify when someone mentions you + identity_rep: + type: boolean + description: Notify when someone gives you REP + identity_nic: + type: boolean + description: Notify when someone gives you NIC + drop_quoted: + type: boolean + description: Notify when someone quotes your drop + drop_replied: + type: boolean + description: Notify when someone replies to your drop + drop_voted: + type: boolean + description: Notify when someone votes on your drop + drop_reacted: + type: boolean + description: Notify when someone reacts to your drop + drop_boosted: + type: boolean + description: Notify when someone boosts your drop + wave_created: + type: boolean + description: Notify when you are invited to a wave + ApiPushNotificationSettingsUpdate: + type: object + properties: + identity_subscribed: + type: boolean + identity_mentioned: + type: boolean + identity_rep: + type: boolean + identity_nic: + type: boolean + drop_quoted: + type: boolean + drop_replied: + type: boolean + drop_voted: + type: boolean + drop_reacted: + type: boolean + drop_boosted: + type: boolean + wave_created: + type: boolean ApiQuotedDrop: type: object required: @@ -8655,7 +8869,6 @@ components: - participation - chat - wave - - outcomes - created_at - contributors_overview - subscribed_actions @@ -8695,10 +8908,6 @@ components: $ref: "#/components/schemas/ApiWaveChatConfig" wave: $ref: "#/components/schemas/ApiWaveConfig" - outcomes: - type: array - items: - $ref: "#/components/schemas/ApiWaveOutcomeOld" contributors_overview: type: array items: @@ -9133,33 +9342,6 @@ components: type: array items: $ref: "#/components/schemas/ApiWaveOutcomeDistributionItem" - ApiWaveOutcomeOld: - type: object - required: - - type - - description - - index - properties: - type: - $ref: "#/components/schemas/ApiWaveOutcomeType" - subtype: - $ref: "#/components/schemas/ApiWaveOutcomeSubType" - description: - type: string - credit: - $ref: "#/components/schemas/ApiWaveOutcomeCredit" - rep_category: - type: string - amount: - type: number - format: int64 - distribution: - type: array - items: - $ref: "#/components/schemas/ApiWaveOutcomeDistributionItem" - index: - type: number - format: int64 ApiWaveOutcomesPage: type: object required: diff --git a/scripts/refresh-api.sh b/scripts/refresh-api.sh index 3bc5db733b..1afc702156 100644 --- a/scripts/refresh-api.sh +++ b/scripts/refresh-api.sh @@ -4,4 +4,4 @@ if [ $# -eq 0 ] then BRANCH='main' fi -curl https://raw.githubusercontent.com/6529-Collections/6529seize-backend/${BRANCH}/src/api-serverless/openapi.yaml > openapi.yaml \ No newline at end of file +curl -H 'Accept: application/vnd.github.v3.raw' "https://api.github.com/repos/6529-Collections/6529seize-backend/contents/src/api-serverless/openapi.yaml?ref=${BRANCH}" > openapi.yaml \ No newline at end of file diff --git a/types/feed.types.ts b/types/feed.types.ts index 9de734de31..be8aa1fd02 100644 --- a/types/feed.types.ts +++ b/types/feed.types.ts @@ -33,122 +33,115 @@ export type TypedFeedItem = | IFeedItemDropCreated | IFeedItemDropReplied; -export type INotificationIdentitySubscribed = { +/** + * Base notification fields shared by all notification types. + */ +type NotificationBase = { readonly id: number; - readonly cause: ApiNotificationCause.IdentitySubscribed; readonly created_at: number; readonly read_at: number | null; readonly related_identity: ApiProfileMin; }; -export type INotificationIdentityMentioned = { - readonly id: number; - readonly cause: ApiNotificationCause.IdentityMentioned; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; +type WithDrops = { readonly related_drops: Array; }; -export type INotificationDropVoted = { - readonly id: number; - readonly cause: ApiNotificationCause.DropVoted; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; - readonly related_drops: Array; +export type INotificationIdentitySubscribed = NotificationBase & { + readonly cause: ApiNotificationCause.IdentitySubscribed; +}; + +export type INotificationIdentityRep = NotificationBase & { + readonly cause: ApiNotificationCause.IdentityRep; readonly additional_context: { - readonly vote: number; + readonly amount: number; + readonly total: number; + readonly category: string; }; }; -export type INotificationDropReacted = { - readonly id: number; - readonly cause: ApiNotificationCause.DropReacted; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; - readonly related_drops: Array; +export type INotificationIdentityNic = NotificationBase & { + readonly cause: ApiNotificationCause.IdentityNic; readonly additional_context: { - readonly reaction: string; + readonly amount: number; + readonly total: number; }; }; -export type INotificationDropBoosted = { - readonly id: number; - readonly cause: ApiNotificationCause.DropBoosted; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; - readonly related_drops: Array; - readonly additional_context: Record; -}; +export type INotificationIdentityMentioned = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.IdentityMentioned; + }; -export type INotificationDropQuoted = { - readonly id: number; - readonly cause: ApiNotificationCause.DropQuoted; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; - readonly related_drops: Array; - readonly additional_context: { - readonly quote_drop_id: string; - readonly quote_drop_part: string; - readonly quoted_drop_id: string; - readonly quoted_drop_part: string; +export type INotificationDropVoted = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.DropVoted; + readonly additional_context: { + readonly vote: number; + }; }; -}; -export type INotificationDropReplied = { - readonly id: number; - readonly cause: ApiNotificationCause.DropReplied; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; - readonly related_drops: Array; - readonly additional_context: { - readonly reply_drop_id: string; - readonly replied_drop_id: string; - readonly replied_drop_part: string; +export type INotificationDropReacted = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.DropReacted; + readonly additional_context: { + readonly reaction: string; + }; }; -}; -export type INotificationWaveCreated = { - readonly id: number; +export type INotificationDropBoosted = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.DropBoosted; + readonly additional_context: Record; + }; + +export type INotificationDropQuoted = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.DropQuoted; + readonly additional_context: { + readonly quote_drop_id: string; + readonly quote_drop_part: string; + readonly quoted_drop_id: string; + readonly quoted_drop_part: string; + }; + }; + +export type INotificationDropReplied = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.DropReplied; + readonly additional_context: { + readonly reply_drop_id: string; + readonly replied_drop_id: string; + readonly replied_drop_part: string; + }; + }; + +export type INotificationWaveCreated = NotificationBase & { readonly cause: ApiNotificationCause.WaveCreated; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; readonly additional_context: { readonly wave_id: string; }; }; -export type INotificationAllDrops = { - readonly id: number; - readonly cause: ApiNotificationCause.AllDrops; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; - readonly related_drops: Array; - readonly additional_context: { - readonly vote: number; +export type INotificationAllDrops = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.AllDrops; + readonly additional_context: { + readonly vote: number; + }; }; -}; -export type INotificationPriorityAlert = { - readonly id: number; - readonly cause: ApiNotificationCause.PriorityAlert; - readonly created_at: number; - readonly read_at: number | null; - readonly related_identity: ApiProfileMin; - readonly related_drops: Array; - readonly additional_context: any; -}; +export type INotificationPriorityAlert = NotificationBase & + WithDrops & { + readonly cause: ApiNotificationCause.PriorityAlert; + readonly additional_context: Record; + }; export type TypedNotification = | INotificationIdentitySubscribed | INotificationIdentityMentioned + | INotificationIdentityRep + | INotificationIdentityNic | INotificationDropVoted | INotificationDropReacted | INotificationDropBoosted @@ -158,6 +151,20 @@ export type TypedNotification = | INotificationAllDrops | INotificationPriorityAlert; +/** + * Fallback type for unknown/unsupported notification causes. + * Used to render generic notifications that don't match known causes. + */ +export type INotificationGeneric = { + readonly id: number; + readonly cause: string; + readonly created_at: number; + readonly read_at: number | null; + readonly related_identity?: ApiProfileMin; + readonly related_drops?: Array; + readonly additional_context?: Record; +}; + export interface TypedNotificationsResponse extends Omit< ApiNotificationsResponse, "notifications"