diff --git a/packages/app/src/Element/NotificationChart.tsx b/packages/app/src/Element/NotificationChart.tsx new file mode 100644 index 00000000..87e32946 --- /dev/null +++ b/packages/app/src/Element/NotificationChart.tsx @@ -0,0 +1,177 @@ +import { Day } from "@/Const"; +import Icon from "@/Icons/Icon"; +import { formatShort } from "@/Number"; +import { orderAscending } from "@/SnortUtils"; +import { unixNow, unwrap } from "@snort/shared"; +import { TaggedNostrEvent, EventKind } from "@snort/system"; +import classNames from "classnames"; +import { useState, useMemo } from "react"; +import { FormattedMessage } from "react-intl"; +import { AsyncIcon } from "./AsyncIcon"; +import Tabs, { Tab } from "./Tabs"; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +interface StatSlot { + time: string; + reactions: number; + reposts: number; + quotes: number; + mentions: number; + zaps: number; +} + +const enum NotificationSummaryPeriod { + Daily, + Weekly, +} + +const enum NotificationSummaryFilter { + Reactions = 1, + Reposts = 2, + Mentions = 4, + Zaps = 8, + All = 255, +} + +export default function NotificationSummary({ evs }: { evs: Array }) { + const [period, setPeriod] = useState(NotificationSummaryPeriod.Daily); + const [filter, setFilter] = useState(NotificationSummaryFilter.All); + + const periodTabs = [ + { + value: NotificationSummaryPeriod.Daily, + text: , + }, + { + value: NotificationSummaryPeriod.Weekly, + text: , + }, + ] as Array; + + const hasFlag = (v: number, f: NotificationSummaryFilter) => { + return (v & f) > 0; + }; + + const getWeek = (d: Date) => { + const onejan = new Date(d.getFullYear(), 0, 1); + const today = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const dayOfYear = (today.getTime() - onejan.getTime() + 86400000) / 86400000; + return Math.ceil(dayOfYear / 7); + }; + + const stats = useMemo(() => { + return orderAscending(evs) + .filter(a => (period === NotificationSummaryPeriod.Daily ? a.created_at > unixNow() - 14 * Day : true)) + .reduce( + (acc, v) => { + const date = new Date(v.created_at * 1000); + const key = + period === NotificationSummaryPeriod.Daily + ? `${date.getMonth() + 1}/${date.getDate()}` + : getWeek(date).toString(); + acc[key] ??= { + time: key, + reactions: 0, + reposts: 0, + quotes: 0, + mentions: 0, + zaps: 0, + }; + + if (v.kind === EventKind.Reaction) { + acc[key].reactions++; + } else if (v.kind === EventKind.Repost) { + acc[key].reposts++; + } else if (v.kind === EventKind.ZapReceipt) { + acc[key].zaps++; + } + if (v.kind === EventKind.TextNote) { + acc[key].mentions++; + } + + return acc; + }, + {} as Record, + ); + }, [evs, period]); + + if (evs.length === 0) return; + + const filterIcon = (f: NotificationSummaryFilter, icon: string, iconActiveClass: string) => { + const active = hasFlag(filter, f); + return ( + setFilter(v => v ^ f)} + name={""} + iconName={icon} + /> + ); + }; + + return ( +
+
+

+ +

+
+ {filterIcon(NotificationSummaryFilter.Reactions, "heart-solid", "text-heart")} + {filterIcon(NotificationSummaryFilter.Zaps, "zap-solid", "text-zap")} + {filterIcon(NotificationSummaryFilter.Reposts, "reverse-left", "text-repost")} + {filterIcon(NotificationSummaryFilter.Mentions, "at-sign", "text-mention")} +
+
+ a.value === period))} setTab={t => setPeriod(t.value)} /> +
+ + + + + {hasFlag(filter, NotificationSummaryFilter.Reactions) && ( + + )} + {hasFlag(filter, NotificationSummaryFilter.Reposts) && ( + + )} + {hasFlag(filter, NotificationSummaryFilter.Mentions) && ( + + )} + {hasFlag(filter, NotificationSummaryFilter.Zaps) && } + { + if (active && payload && payload.length) { + return ( +
+
+ + {formatShort(payload.find(a => a.name === "reactions")?.value as number)} +
+
+ + {formatShort(payload.find(a => a.name === "zaps")?.value as number)} +
+
+ + {formatShort(payload.find(a => a.name === "reposts")?.value as number)} +
+
+ + {formatShort(payload.find(a => a.name === "mentions")?.value as number)} +
+
+ ); + } + return null; + }} + /> +
+
+
+
+ ); +} \ No newline at end of file diff --git a/packages/app/src/Pages/Notifications.tsx b/packages/app/src/Pages/Notifications.tsx index c41c4895..451283c4 100644 --- a/packages/app/src/Pages/Notifications.tsx +++ b/packages/app/src/Pages/Notifications.tsx @@ -1,17 +1,16 @@ import "./Notifications.css"; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { Suspense, lazy, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { EventExt, EventKind, NostrEvent, NostrLink, NostrPrefix, TaggedNostrEvent, parseZap } from "@snort/system"; -import { unixNow, unwrap } from "@snort/shared"; +import { unwrap } from "@snort/shared"; import { useEventFeed, useUserProfile } from "@snort/system-react"; import { useInView } from "react-intersection-observer"; import { FormattedMessage, useIntl } from "react-intl"; import { useNavigate } from "react-router-dom"; -import { Bar, BarChart, Tooltip, XAxis, YAxis } from "recharts"; import useLogin from "@/Hooks/useLogin"; import { markNotificationsRead } from "@/Login"; import { Notifications } from "@/Cache"; -import { dedupe, orderAscending, orderDescending, getDisplayName } from "@/SnortUtils"; +import { dedupe, orderDescending, getDisplayName } from "@/SnortUtils"; import Icon from "@/Icons/Icon"; import ProfileImage from "@/Element/User/ProfileImage"; import useModeration from "@/Hooks/useModeration"; @@ -19,11 +18,9 @@ import Text from "@/Element/Text"; import { formatShort } from "@/Number"; import { LiveEvent } from "@/Element/LiveEvent"; import ProfilePreview from "@/Element/User/ProfilePreview"; -import { Day } from "@/Const"; -import Tabs, { Tab } from "@/Element/Tabs"; -import classNames from "classnames"; -import { AsyncIcon } from "@/Element/AsyncIcon"; import { ShowMoreInView } from "@/Element/Event/ShowMore"; +import PageSpinner from "@/Element/PageSpinner"; +const NotificationGraph = lazy(() => import("@/Element/NotificationChart")); function notificationContext(ev: TaggedNostrEvent) { switch (ev.kind) { @@ -105,8 +102,9 @@ export default function NotificationsPage({ onClick }: { onClick?: (link: NostrL
- - + }> + + {login.publicKey && [...timeGrouped.entries()] .slice(0, showN) @@ -118,172 +116,6 @@ export default function NotificationsPage({ onClick }: { onClick?: (link: NostrL ); } -interface StatSlot { - time: string; - reactions: number; - reposts: number; - quotes: number; - mentions: number; - zaps: number; -} - -const enum NotificationSummaryPeriod { - Daily, - Weekly, -} - -const enum NotificationSummaryFilter { - Reactions = 1, - Reposts = 2, - Mentions = 4, - Zaps = 8, - All = 255, -} - -function NotificationSummary({ evs }: { evs: Array }) { - const ref = useRef(null); - const [period, setPeriod] = useState(NotificationSummaryPeriod.Daily); - const [filter, setFilter] = useState(NotificationSummaryFilter.All); - - const periodTabs = [ - { - value: NotificationSummaryPeriod.Daily, - text: , - }, - { - value: NotificationSummaryPeriod.Weekly, - text: , - }, - ] as Array; - - const hasFlag = (v: number, f: NotificationSummaryFilter) => { - return (v & f) > 0; - }; - - const getWeek = (d: Date) => { - const onejan = new Date(d.getFullYear(), 0, 1); - const today = new Date(d.getFullYear(), d.getMonth(), d.getDate()); - const dayOfYear = (today.getTime() - onejan.getTime() + 86400000) / 86400000; - return Math.ceil(dayOfYear / 7); - }; - - const stats = useMemo(() => { - return orderAscending(evs) - .filter(a => (period === NotificationSummaryPeriod.Daily ? a.created_at > unixNow() - 14 * Day : true)) - .reduce( - (acc, v) => { - const date = new Date(v.created_at * 1000); - const key = - period === NotificationSummaryPeriod.Daily - ? `${date.getMonth() + 1}/${date.getDate()}` - : getWeek(date).toString(); - acc[key] ??= { - time: key, - reactions: 0, - reposts: 0, - quotes: 0, - mentions: 0, - zaps: 0, - }; - - if (v.kind === EventKind.Reaction) { - acc[key].reactions++; - } else if (v.kind === EventKind.Repost) { - acc[key].reposts++; - } else if (v.kind === EventKind.ZapReceipt) { - acc[key].zaps++; - } - if (v.kind === EventKind.TextNote) { - acc[key].mentions++; - } - - return acc; - }, - {} as Record, - ); - }, [evs, period]); - - if (evs.length === 0) return; - - const filterIcon = (f: NotificationSummaryFilter, icon: string, iconActiveClass: string) => { - const active = hasFlag(filter, f); - return ( - setFilter(v => v ^ f)} - name={""} - iconName={icon} - /> - ); - }; - - return ( -
-
-

- -

-
- {filterIcon(NotificationSummaryFilter.Reactions, "heart-solid", "text-heart")} - {filterIcon(NotificationSummaryFilter.Zaps, "zap-solid", "text-zap")} - {filterIcon(NotificationSummaryFilter.Reposts, "reverse-left", "text-repost")} - {filterIcon(NotificationSummaryFilter.Mentions, "at-sign", "text-mention")} -
-
- a.value === period))} setTab={t => setPeriod(t.value)} /> -
- - - - {hasFlag(filter, NotificationSummaryFilter.Reactions) && ( - - )} - {hasFlag(filter, NotificationSummaryFilter.Reposts) && ( - - )} - {hasFlag(filter, NotificationSummaryFilter.Mentions) && ( - - )} - {hasFlag(filter, NotificationSummaryFilter.Zaps) && } - { - if (active && payload && payload.length) { - return ( -
-
- - {formatShort(payload.find(a => a.name === "reactions")?.value as number)} -
-
- - {formatShort(payload.find(a => a.name === "zaps")?.value as number)} -
-
- - {formatShort(payload.find(a => a.name === "reposts")?.value as number)} -
-
- - {formatShort(payload.find(a => a.name === "mentions")?.value as number)} -
-
- ); - } - return null; - }} - /> -
-
-
- ); -} - function NotificationGroup({ evs, onClick }: { evs: Array; onClick?: (link: NostrLink) => void }) { const { ref, inView } = useInView({ triggerOnce: true }); const { formatMessage } = useIntl();