notifications v6

This commit is contained in:
hzrd149 2024-08-31 09:28:22 -05:00
parent 8deeec45ce
commit a5f04d802b
14 changed files with 571 additions and 223 deletions

View File

@ -7,7 +7,7 @@ export default function Timestamp({ timestamp, ...props }: { timestamp: number }
let display = date.format("L");
if (now.diff(date, "week") <= 2) {
if (now.diff(date, "week") <= 6) {
if (now.diff(date, "d") >= 1) {
display = Math.round(now.diff(date, "d") * 10) / 10 + `d`;
} else if (now.diff(date, "h") >= 1) {

View File

@ -0,0 +1,13 @@
import { useCallback, useMemo } from "react";
import readStatusService from "../services/read-status";
import useSubject from "./use-subject";
export default function useReadStatus(key: string, ttl?: number) {
const subject = useMemo(() => readStatusService.getStatus(key, ttl), [key]);
const setRead = useCallback((read = true) => readStatusService.setRead(key, read, ttl), [key, ttl]);
const read = useSubject(subject);
return [read, setRead] as const;
}

View File

@ -1,15 +1,15 @@
import { openDB, deleteDB, IDBPDatabase, IDBPTransaction } from "idb";
import { clearDB, deleteDB as nostrIDBDelete } from "nostr-idb";
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6, SchemaV7, SchemaV8 } from "./schema";
import { SchemaV1, SchemaV2, SchemaV3, SchemaV4, SchemaV5, SchemaV6, SchemaV7, SchemaV8, SchemaV9 } from "./schema";
import { logger } from "../../helpers/debug";
import { localDatabase } from "../local-relay";
const log = logger.extend("Database");
const dbName = "storage";
const version = 8;
const db = await openDB<SchemaV8>(dbName, version, {
const version = 9;
const db = await openDB<SchemaV9>(dbName, version, {
upgrade(db, oldVersion, newVersion, transaction, event) {
if (oldVersion < 1) {
const v0 = db as unknown as IDBPDatabase<SchemaV1>;
@ -171,6 +171,13 @@ const db = await openDB<SchemaV8>(dbName, version, {
const v7 = db as unknown as IDBPDatabase<SchemaV7>;
v7.deleteObjectStore("replaceableEvents");
}
if (oldVersion < 9) {
const v9 = db as unknown as IDBPDatabase<SchemaV9>;
const readStore = v9.createObjectStore("read", { keyPath: "key" });
readStore.createIndex("ttl", "ttl");
}
},
});

View File

@ -141,3 +141,15 @@ export interface SchemaV7 extends Omit<SchemaV6, "accounts"> {
}
export interface SchemaV8 extends Omit<SchemaV7, "replaceableEvents"> {}
export interface SchemaV9 extends SchemaV8 {
read: {
key: string;
value: {
key: string;
ttl: number;
read: boolean;
};
indexes: { ttl: number };
};
}

106
src/services/read-status.ts Normal file
View File

@ -0,0 +1,106 @@
import dayjs from "dayjs";
import _throttle from "lodash.throttle";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import db from "./db";
import { logger } from "../helpers/debug";
class ReadStatusService {
log = logger.extend("ReadStatusService");
status = new SuperMap<string, Subject<boolean>>(() => new Subject());
ttl = new Map<string, number>();
private setTTL(key: string, ttl: number) {
const current = this.ttl.get(key);
if (!current || ttl > current) {
this.ttl.set(key, ttl);
}
}
getStatus(key: string, ttl?: number) {
const subject = this.status.get(key);
if (ttl) this.setTTL(key, ttl);
else this.setTTL(key, dayjs().add(1, "day").unix());
if (subject.value === undefined && !this.queue.has(key)) {
this.queue.add(key);
this.throttleRead();
}
return subject;
}
setRead(key: string, read = true, ttl?: number) {
if (ttl) this.setTTL(key, ttl);
else this.setTTL(key, dayjs().add(1, "day").unix());
this.status.get(key).next(read);
this.throttleWrite();
}
queue = new Set<string>();
private throttleRead = _throttle(this.read.bind(this), 1000);
async read() {
if (this.queue.size === 0) return;
const trans = db.transaction("read");
this.log(`Loading ${this.queue.size} from database`);
await Promise.all(
Array.from(this.queue).map(async (key) => {
const subject = this.status.get(key);
const status = await trans.store.get(key);
if (status) {
subject.next(status.read);
if (status.ttl) this.setTTL(key, status.ttl);
} else subject.next(false);
}),
);
this.queue.clear();
}
throttleWrite = _throttle(this.write.bind(this), 1000);
async write() {
const trans = db.transaction("read", "readwrite");
let count = 0;
const defaultTTL = dayjs().add(1, "day").unix();
for (const [key, subject] of this.status) {
if (subject.value !== undefined) {
trans.store.add({ key, read: subject.value, ttl: this.ttl.get(key) ?? defaultTTL });
count++;
}
}
await trans.done;
this.log(`Wrote ${count} to database`);
}
async prune() {
const expired = await db.getAllKeysFromIndex("read", "ttl", IDBKeyRange.lowerBound(dayjs().unix(), true));
if (expired.length === 0) return;
const tx = db.transaction("read", "readwrite");
await Promise.all(expired.map((key) => tx.store.delete(key)));
await tx.done;
this.log(`Removed ${expired.length} expired entries`);
}
}
const readStatusService = new ReadStatusService();
setInterval(readStatusService.write.bind(readStatusService), 10_000);
setInterval(readStatusService.prune.bind(readStatusService), 30_000);
if (import.meta.env.DEV) {
// @ts-expect-error
window.readStatusService = readStatusService;
}
export default readStatusService;

View File

@ -6,6 +6,8 @@ import { useNotifications } from "../../../providers/global/notifications-provid
import useSubject from "../../../hooks/use-subject";
import { NotificationType, typeSymbol } from "../../../classes/notifications";
import NotificationItem from "../../notifications/components/notification-item";
import { useCallback } from "react";
import { NostrEvent } from "nostr-tools";
export default function NotificationsCard({ ...props }: Omit<CardProps, "children">) {
const navigate = useNavigate();
@ -21,6 +23,13 @@ export default function NotificationsCard({ ...props }: Omit<CardProps, "childre
const limit = events.length > 20 ? events.slice(0, 20) : events;
const handleClick = useCallback(
(event: NostrEvent) => {
navigate("/notifications", { state: { focused: event.id } });
},
[navigate],
);
return (
<Card variant="outline" {...props}>
<CardHeader display="flex" justifyContent="space-between" alignItems="center" pb="2">
@ -33,7 +42,7 @@ export default function NotificationsCard({ ...props }: Omit<CardProps, "childre
</CardHeader>
<CardBody overflowX="hidden" overflowY="auto" pt="4" display="flex" gap="2" flexDirection="column" maxH="50vh">
{limit.map((event) => (
<NotificationItem event={event} key={event.id} />
<NotificationItem event={event} key={event.id} onClick={handleClick} />
))}
<Button as={RouterLink} to="/notifications" flexShrink={0} variant="link" size="lg" py="6">
View More

View File

@ -1,19 +1,86 @@
import { Box, Flex } from "@chakra-ui/react";
import { PropsWithChildren, ReactNode, forwardRef } from "react";
import { Box, Flex, Spacer, Text } from "@chakra-ui/react";
import { PropsWithChildren, ReactNode, forwardRef, memo, useCallback, useContext, useEffect } from "react";
import UserAvatar from "../../../components/user/user-avatar";
import Timestamp from "../../../components/timestamp";
import UserName from "../../../components/user/user-name";
import { CheckIcon } from "../../../components/icons";
import FocusedContext from "../focused-context";
const NotificationIconEntry = forwardRef<HTMLDivElement, PropsWithChildren<{ icon: ReactNode }>>(
({ children, icon }, ref) => {
return (
<Flex gap="2" ref={ref}>
<Box px="2" pb="2">
{icon}
</Box>
<Flex direction="column" w="full" gap="2" overflow="hidden">
{children}
// const ONE_MONTH = 60 * 60 * 24 * 30;
type NotificationIconEntryProps = PropsWithChildren<{
icon: ReactNode;
pubkey: string;
timestamp: number;
summary: ReactNode;
id: string;
onClick?: () => void;
}>;
const NotificationIconEntry = memo(
forwardRef<HTMLDivElement, NotificationIconEntryProps>(
({ children, icon, pubkey, timestamp, summary, id, onClick }, ref) => {
const { id: focused, focus } = useContext(FocusedContext);
// const [read, setRead] = useReadStatus(id, ONE_MONTH);
const expanded = focused === id;
const focusSelf = useCallback(() => focus(id), [id, focus]);
// scroll element to stop when opened
useEffect(() => {
if (expanded) {
// @ts-expect-error
ref.current?.scrollIntoView();
}
}, [expanded]);
// useEffect(() => {
// if (!read && expanded) setRead(true);
// }, [read, expanded]);
return (
<Flex direction="column" bg={expanded ? "whiteAlpha.100" : undefined} rounded="md">
<Flex
gap="2"
alignItems="center"
ref={ref}
cursor="pointer"
p="2"
tabIndex={0}
onFocus={onClick ? undefined : focusSelf}
onClick={onClick}
userSelect="none"
bg={expanded ? "whiteAlpha.100" : undefined}
>
<Box>{icon}</Box>
<UserAvatar pubkey={pubkey} size="sm" />
<UserName pubkey={pubkey} hideBelow="md" />
<Text isTruncated>{summary}</Text>
<Spacer />
{/* {read && <CheckIcon boxSize={5} color="green.500" />} */}
<Timestamp timestamp={timestamp} />
</Flex>
{expanded && (
<Flex
direction="column"
w="full"
gap="2"
overflow="hidden"
minH={{ base: "calc(100vh - 10rem)", lg: "xs" }}
p="2"
maxH="calc(100vh - 10rem)"
overflowY="auto"
overflowX="hidden"
>
{children}
</Flex>
)}
</Flex>
</Flex>
);
},
);
},
),
);
export default NotificationIconEntry;

View File

@ -1,13 +1,14 @@
import { ReactNode, forwardRef, memo, useMemo } from "react";
import { AvatarGroup, ButtonGroup, Flex, IconButton, IconButtonProps, Text, useDisclosure } from "@chakra-ui/react";
import { ReactNode, forwardRef, memo, useCallback, useMemo } from "react";
import { AvatarGroup, ButtonGroup, Flex, IconButton, IconButtonProps, Text } from "@chakra-ui/react";
import { kinds, nip18, nip25 } from "nostr-tools";
import { DecodeResult } from "nostr-tools/nip19";
import useCurrentAccount from "../../../hooks/use-current-account";
import { NostrEvent, isATag, isETag } from "../../../types/nostr-event";
import { getParsedZap } from "../../../helpers/nostr/zaps";
import { readablizeSats } from "../../../helpers/bolt11";
import { parseCoordinate } from "../../../helpers/nostr/event";
import { EmbedEvent, EmbedEventPointer } from "../../../components/embed-event";
import { getThreadReferences, parseCoordinate } from "../../../helpers/nostr/event";
import { EmbedEventPointer } from "../../../components/embed-event";
import EmbeddedUnknown from "../../../components/embed-event/event-types/embedded-unknown";
import { ErrorBoundary } from "../../../components/error-boundary";
import { TrustProvider } from "../../../providers/local/trust-provider";
@ -26,6 +27,12 @@ import NotificationIconEntry from "./notification-icon-entry";
import { CategorizedEvent, NotificationType, typeSymbol } from "../../../classes/notifications";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
import ZapReceiptMenu from "../../../components/zap/zap-receipt-menu";
import ReactionIcon from "../../../components/event-reactions/reaction-icon";
import { TimelineNote } from "../../../components/note/timeline-note";
import UserAvatar from "../../../components/user/user-avatar";
import UserName from "../../../components/user/user-name";
import { truncateId } from "../../../helpers/string";
import TextNoteContents from "../../../components/note/timeline-note/text-note-contents";
export const ExpandableToggleButton = ({
toggle,
@ -39,125 +46,191 @@ export const ExpandableToggleButton = ({
/>
);
const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
<NotificationIconEntry ref={ref} icon={<ReplyIcon boxSize={8} color="green.400" />}>
<EmbedEvent event={event} />
</NotificationIconEntry>
));
const ReplyNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => {
const refs = getThreadReferences(event);
const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => (
<NotificationIconEntry ref={ref} icon={<AtIcon boxSize={8} color="purple.400" />}>
<EmbedEvent event={event} />
</NotificationIconEntry>
));
const pointer = useMemo<DecodeResult | undefined>(() => {
if (refs.reply?.a) return { type: "naddr", data: refs.reply.a };
if (refs.reply?.e) return { type: "nevent", data: refs.reply.e };
}, [refs.reply?.e, refs.reply?.a]);
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const pointer = nip18.getRepostedEventPointer(event);
const expanded = useDisclosure({ defaultIsOpen: true });
if (!pointer) return null;
return (
<NotificationIconEntry ref={ref} icon={<RepostIcon boxSize={8} color="blue.400" />}>
<Flex gap="2" alignItems="center">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</NotificationIconEntry>
);
});
const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const account = useCurrentAccount();
const pointer = nip25.getReactedEventPointer(event);
const expanded = useDisclosure({ defaultIsOpen: true });
if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null;
const reactedEvent = useSingleEvent(pointer.id, pointer.relays);
if (reactedEvent?.kind === kinds.EncryptedDirectMessage) return null;
return (
<NotificationIconEntry ref={ref} icon={<Heart boxSize={8} color="red.400" />}>
<Flex gap="2" alignItems="center" pl="2">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<Text fontSize="xl">{event.content}</Text>
<ExpandableToggleButton aria-label="Toggle event" ml="auto" toggle={expanded} />
{/* <Timestamp timestamp={event.created_at} ml="auto" /> */}
</Flex>
{expanded.isOpen && <EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />}
</NotificationIconEntry>
);
});
const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
const zap = useMemo(() => getParsedZap(event), [event]);
if (!zap || !zap.payment.amount) return null;
const eventId = zap?.request.tags.find(isETag)?.[1];
const coordinate = zap?.request.tags.find(isATag)?.[1];
const parsedCoordinate = coordinate ? parseCoordinate(coordinate) : null;
const expanded = useDisclosure({ defaultIsOpen: true });
let eventJSX: ReactNode | null = null;
if (parsedCoordinate && parsedCoordinate.identifier) {
eventJSX = (
<EmbedEventPointer
pointer={{
type: "naddr",
data: {
pubkey: parsedCoordinate.pubkey,
identifier: parsedCoordinate.identifier,
kind: parsedCoordinate.kind,
},
}}
/>
return (
<NotificationIconEntry
ref={ref}
icon={<ReplyIcon boxSize={8} color="green.400" />}
id={event.id}
pubkey={event.pubkey}
timestamp={event.created_at}
summary={event.content}
onClick={onClick}
>
{pointer && <EmbedEventPointer pointer={pointer} />}
<TimelineNote event={event} showReplyLine={false} />
</NotificationIconEntry>
);
} else if (eventId) {
eventJSX = <EmbedEventPointer pointer={{ type: "note", data: eventId }} />;
}
},
);
return (
<NotificationIconEntry ref={ref} icon={<LightningIcon boxSize={8} color="yellow.400" />}>
<Flex gap="2" alignItems="center" pl="2">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={zap.request.pubkey} />
</AvatarGroup>
<Text>{readablizeSats(zap.payment.amount / 1000)} sats</Text>
{zap.request.content && <Text>{zap.request.content}</Text>}
<ButtonGroup size="sm" variant="ghost" ml="auto">
{eventJSX !== null && <ExpandableToggleButton aria-label="Toggle event" toggle={expanded} />}
<ZapReceiptMenu zap={zap.event} aria-label="More Options" />
</ButtonGroup>
</Flex>
{expanded.isOpen && eventJSX}
const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => (
<NotificationIconEntry
ref={ref}
icon={<AtIcon boxSize={8} color="purple.400" />}
id={event.id}
pubkey={event.pubkey}
timestamp={event.created_at}
summary={event.content}
onClick={onClick}
>
<TimelineNote event={event} showReplyButton />
</NotificationIconEntry>
);
});
),
);
const NotificationItem = ({ event }: { event: CategorizedEvent }) => {
const RepostNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => {
const pointer = nip18.getRepostedEventPointer(event);
if (!pointer) return null;
return (
<NotificationIconEntry
ref={ref}
icon={<RepostIcon boxSize={8} color="blue.400" />}
id={event.id}
pubkey={event.pubkey}
timestamp={event.created_at}
summary={<>Reposted {truncateId(pointer.id)}</>}
onClick={onClick}
>
<Text>
<UserAvatar size="xs" pubkey={event.pubkey} /> <UserName pubkey={event.pubkey} /> reposted:
</Text>
<EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />
</NotificationIconEntry>
);
},
);
const ReactionNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => {
const account = useCurrentAccount();
const pointer = nip25.getReactedEventPointer(event);
if (!pointer || (account?.pubkey && pointer.author !== account.pubkey)) return null;
const reactedEvent = useSingleEvent(pointer.id, pointer.relays);
if (reactedEvent?.kind === kinds.EncryptedDirectMessage) return null;
return (
<NotificationIconEntry
ref={ref}
icon={<Heart boxSize={8} color="red.400" />}
id={event.id}
pubkey={event.pubkey}
timestamp={event.created_at}
summary={
<>
<ReactionIcon emoji={event.content} />
{reactedEvent?.content}
</>
}
onClick={onClick}
>
<Flex gap="2" alignItems="center" pl="2">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={event.pubkey} />
</AvatarGroup>
<Text>
reacted with <ReactionIcon emoji={event.content} />
</Text>
</Flex>
<EmbedEventPointer pointer={{ type: "nevent", data: pointer }} />
</NotificationIconEntry>
);
},
);
const ZapNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => {
const zap = useMemo(() => getParsedZap(event), [event]);
if (!zap || !zap.payment.amount) return null;
const eventId = zap?.request.tags.find(isETag)?.[1];
const coordinate = zap?.request.tags.find(isATag)?.[1];
const parsedCoordinate = coordinate ? parseCoordinate(coordinate) : null;
let eventJSX: ReactNode | null = null;
if (parsedCoordinate && parsedCoordinate.identifier) {
eventJSX = (
<EmbedEventPointer
pointer={{
type: "naddr",
data: {
pubkey: parsedCoordinate.pubkey,
identifier: parsedCoordinate.identifier,
kind: parsedCoordinate.kind,
},
}}
/>
);
} else if (eventId) {
eventJSX = <EmbedEventPointer pointer={{ type: "note", data: eventId }} />;
}
return (
<NotificationIconEntry
ref={ref}
icon={<LightningIcon boxSize={8} color="yellow.400" />}
id={event.id}
pubkey={zap.request.pubkey}
timestamp={zap.request.created_at}
summary={
<>
{readablizeSats(zap.payment.amount / 1000)} {zap.request.content}
</>
}
onClick={onClick}
>
<Flex gap="2" alignItems="center" pl="2">
<AvatarGroup size="sm">
<UserAvatarLink pubkey={zap.request.pubkey} />
</AvatarGroup>
<Text>zapped {readablizeSats(zap.payment.amount / 1000)} sats</Text>
<ButtonGroup size="sm" variant="ghost" ml="auto">
<ZapReceiptMenu zap={zap.event} aria-label="More Options" />
</ButtonGroup>
</Flex>
<TextNoteContents event={zap.request} />
{eventJSX}
</NotificationIconEntry>
);
},
);
const NotificationItem = ({ event, onClick }: { event: CategorizedEvent; onClick?: (event: NostrEvent) => void }) => {
const ref = useEventIntersectionRef(event);
const handleClick = useCallback(() => {
if (onClick) onClick(event);
}, [onClick, event]);
let content: ReactNode | null = null;
switch (event[typeSymbol]) {
case NotificationType.Reply:
content = <ReplyNotification event={event} ref={ref} />;
content = <ReplyNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Mention:
content = <MentionNotification event={event} ref={ref} />;
content = <MentionNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Reaction:
content = <ReactionNotification event={event} ref={ref} />;
content = <ReactionNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Repost:
content = <RepostNotification event={event} ref={ref} />;
content = <RepostNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
case NotificationType.Zap:
content = <ZapNotification event={event} ref={ref} />;
content = <ZapNotification event={event} onClick={onClick && handleClick} ref={ref} />;
break;
default:
content = <EmbeddedUnknown event={event} />;

View File

@ -0,0 +1,5 @@
import { createContext } from "react";
const FocusedContext = createContext({ id: "", focus: (id: string) => {} });
export default FocusedContext;

View File

@ -1,7 +1,8 @@
import { memo, useMemo } from "react";
import { Button, ButtonGroup, Flex, IconButton, Input } from "@chakra-ui/react";
import { memo, ReactNode, useContext, useMemo } from "react";
import { Button, ButtonGroup, Divider, Flex, Text } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import dayjs from "dayjs";
import { useKeyPressEvent } from "react-use";
import RequireCurrentAccount from "../../providers/route/require-current-account";
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
@ -13,23 +14,24 @@ import PeopleListSelection from "../../components/people-list-selection/people-l
import VerticalPageLayout from "../../components/vertical-page-layout";
import NotificationItem from "./components/notification-item";
import NotificationTypeToggles from "./notification-type-toggles";
import { ChevronLeftIcon, ChevronRightIcon } from "../../components/icons";
import useRouteSearchValue from "../../hooks/use-route-search-value";
import useLocalStorageDisclosure from "../../hooks/use-localstorage-disclosure";
import { NotificationType, typeSymbol } from "../../classes/notifications";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import FocusedContext from "./focused-context";
import useRouteStateValue from "../../hooks/use-route-state-value";
const DATE_FORMAT = "YYYY-MM-DD";
// const DATE_FORMAT = "YYYY-MM-DD";
const NotificationsTimeline = memo(
({
day,
// day,
showReplies,
showMentions,
showZaps,
showReposts,
showReactions,
}: {
day: string;
// day: string;
showReplies: boolean;
showMentions: boolean;
showZaps: boolean;
@ -39,15 +41,15 @@ const NotificationsTimeline = memo(
const { notifications } = useNotifications();
const { people } = usePeopleListContext();
const peoplePubkeys = useMemo(() => people?.map((p) => p.pubkey), [people]);
const minTimestamp = dayjs(day, DATE_FORMAT).startOf("day").unix();
const maxTimestamp = dayjs(day, DATE_FORMAT).endOf("day").unix();
// const minTimestamp = dayjs(day, DATE_FORMAT).startOf("day").unix();
// const maxTimestamp = dayjs(day, DATE_FORMAT).endOf("day").unix();
const events = useSubject(notifications?.timeline) ?? [];
const filteredEvents = useMemo(
() =>
events.filter((e) => {
if (e.created_at < minTimestamp || e.created_at > maxTimestamp) return false;
// if (e.created_at < minTimestamp || e.created_at > maxTimestamp) return false;
if (e[typeSymbol] === NotificationType.Zap) {
if (!showZaps) return false;
@ -70,11 +72,44 @@ const NotificationsTimeline = memo(
showReactions,
showReposts,
showZaps,
minTimestamp,
maxTimestamp,
// minTimestamp,
// maxTimestamp,
],
);
// VIM controls
const { id: focused, focus: setFocus } = useContext(FocusedContext);
const navigatePrev = () => {
const focusedEvent = filteredEvents.find((e) => e.id === focused);
if (focusedEvent) {
const i = filteredEvents.indexOf(focusedEvent);
if (i >= 1) {
const prev = filteredEvents[i - 1];
if (prev) setFocus(prev.id);
}
}
};
const navigateNext = () => {
const focusedEvent = filteredEvents.find((e) => e.id === focused);
if (focusedEvent) {
const i = filteredEvents.indexOf(focusedEvent);
if (i !== -1 && i < filteredEvents.length - 2) {
const next = filteredEvents[i + 1];
if (next) setFocus(next.id);
}
}
};
useKeyPressEvent("ArrowUp", navigatePrev);
useKeyPressEvent("ArrowDown", navigateNext);
useKeyPressEvent("k", navigatePrev);
useKeyPressEvent("h", navigatePrev);
useKeyPressEvent("j", navigateNext);
useKeyPressEvent("l", navigateNext);
useKeyPressEvent("H", () => setFocus(filteredEvents[0]?.id ?? ""));
useKeyPressEvent("L", () => setFocus(filteredEvents[filteredEvents.length - 1]?.id ?? ""));
if (filteredEvents.length === 0)
return (
<Flex alignItems="center" justifyContent="center" minH="25vh" fontWeight="bold" fontSize="4xl">
@ -82,69 +117,86 @@ const NotificationsTimeline = memo(
</Flex>
);
return (
<>
{filteredEvents.map((event) => (
<NotificationItem key={event.id} event={event} />
))}
</>
);
const items: ReactNode[] = [];
let prev = dayjs();
for (const event of filteredEvents) {
// insert markers at every day
if (prev.diff(dayjs.unix(event.created_at), "d") > 0) {
prev = dayjs.unix(event.created_at);
items.push(
<Flex gap="4" p="2" key={prev.unix() + "-marker"} alignItems="center">
<Divider />
<Text whiteSpace="pre">{prev.fromNow()}</Text>
<Divider />
</Flex>,
);
}
items.push(<NotificationItem key={event.id} event={event} />);
}
return <>{items}</>;
},
);
function NotificationsPage() {
const { timeline } = useNotifications();
const { value: focused, setValue: setFocused } = useRouteStateValue("focused", "");
const focusContext = useMemo(() => ({ id: focused, focus: setFocused }), [focused, setFocused]);
const showReplies = useLocalStorageDisclosure("notifications-show-replies", true);
const showMentions = useLocalStorageDisclosure("notifications-show-mentions", true);
const showZaps = useLocalStorageDisclosure("notifications-show-zaps", true);
const showReposts = useLocalStorageDisclosure("notifications-show-reposts", true);
const showReactions = useLocalStorageDisclosure("notifications-show-reactions", true);
const today = dayjs().format(DATE_FORMAT);
const { value: day, setValue: setDay } = useRouteSearchValue(
"date",
timeline.timeline.value[0] ? dayjs.unix(timeline.timeline.value[0].created_at).format(DATE_FORMAT) : today,
);
// const today = dayjs().format(DATE_FORMAT);
// const { value: day, setValue: setDay } = useRouteSearchValue(
// "date",
// timeline.timeline.value[0] ? dayjs.unix(timeline.timeline.value[0].created_at).format(DATE_FORMAT) : today,
// );
const nextDay = () => {
setDay((date) => {
const endOfDay = dayjs(date ?? today, DATE_FORMAT)
.endOf("day")
.unix();
// const nextDay = () => {
// setDay((date) => {
// const endOfDay = dayjs(date ?? today, DATE_FORMAT)
// .endOf("day")
// .unix();
// find the next event
for (let i = timeline.timeline.value.length - 1; i > 0; i--) {
const e = timeline.timeline.value[i];
if (e.created_at > endOfDay) return dayjs.unix(e.created_at).format(DATE_FORMAT);
}
// // find the next event
// for (let i = timeline.timeline.value.length - 1; i > 0; i--) {
// const e = timeline.timeline.value[i];
// if (e.created_at > endOfDay) return dayjs.unix(e.created_at).format(DATE_FORMAT);
// }
return dayjs(date ?? today, DATE_FORMAT)
.add(1, "day")
.format(DATE_FORMAT);
});
};
const previousDay = () => {
setDay((date) => {
const startOfDay = dayjs(date ?? today, DATE_FORMAT).unix();
// return dayjs(date ?? today, DATE_FORMAT)
// .add(1, "day")
// .format(DATE_FORMAT);
// });
// };
// const previousDay = () => {
// setDay((date) => {
// const startOfDay = dayjs(date ?? today, DATE_FORMAT).unix();
// find the next event
for (const e of timeline.timeline.value) {
if (e.created_at < startOfDay) return dayjs.unix(e.created_at).format(DATE_FORMAT);
}
// // find the next event
// for (const e of timeline.timeline.value) {
// if (e.created_at < startOfDay) return dayjs.unix(e.created_at).format(DATE_FORMAT);
// }
return dayjs(date ?? today, DATE_FORMAT)
.subtract(1, "day")
.format(DATE_FORMAT);
});
};
// return dayjs(date ?? today, DATE_FORMAT)
// .subtract(1, "day")
// .format(DATE_FORMAT);
// });
// };
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<VerticalPageLayout>
<Flex direction={{ base: "column", lg: "row-reverse" }} gap="2" justifyContent="space-between">
<Flex gap="2" justifyContent="space-between">
{/* <Flex gap="2" justifyContent="space-between">
<IconButton aria-label="Previous" icon={<ChevronLeftIcon boxSize={6} />} onClick={previousDay} />
<Input
maxW="xs"
@ -160,7 +212,7 @@ function NotificationsPage() {
onClick={nextDay}
isDisabled={day === today}
/>
</Flex>
</Flex> */}
<Flex gap="2" wrap="wrap" flex={1}>
<NotificationTypeToggles
@ -180,19 +232,23 @@ function NotificationsPage() {
</Flex>
<IntersectionObserverProvider callback={callback}>
<NotificationsTimeline
day={day}
showReplies={showReplies.isOpen}
showMentions={showMentions.isOpen}
showZaps={showZaps.isOpen}
showReposts={showReposts.isOpen}
showReactions={showReactions.isOpen}
/>
<FocusedContext.Provider value={focusContext}>
<Flex direction="column">
<NotificationsTimeline
// day={day}
showReplies={showReplies.isOpen}
showMentions={showMentions.isOpen}
showZaps={showZaps.isOpen}
showReposts={showReposts.isOpen}
showReactions={showReactions.isOpen}
/>
</Flex>
</FocusedContext.Provider>
</IntersectionObserverProvider>
{/* <TimelineActionAndStatus timeline={timeline} /> */}
<TimelineActionAndStatus timeline={timeline} />
<ButtonGroup mx="auto" mt="4">
{/* <ButtonGroup mx="auto" mt="4">
<Button leftIcon={<ChevronLeftIcon boxSize={6} />} onClick={previousDay}>
Previous
</Button>
@ -201,7 +257,7 @@ function NotificationsPage() {
Next
</Button>
)}
</ButtonGroup>
</ButtonGroup> */}
</VerticalPageLayout>
);
}

View File

@ -170,16 +170,16 @@ export default function DisplaySettings() {
<span>Be careful its easy to hide all notes if you add common words.</span>
</FormHelperText>
</FormControl>
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</Flex>
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</VerticalPageLayout>
);
}

View File

@ -114,16 +114,16 @@ export default function PerformanceSettings() {
<FormHelperText>Enabled: automatically decrypt direct messages</FormHelperText>
</FormControl>
<VerifyEventSettings />
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</Flex>
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</VerticalPageLayout>
);
}

View File

@ -183,16 +183,16 @@ export default function PostSettings() {
client tag to events
</FormHelperText>
</FormControl>
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</Flex>
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</VerticalPageLayout>
);
}

View File

@ -188,16 +188,16 @@ export default function PrivacySettings() {
</span>
</FormHelperText>
</FormControl>
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</Flex>
<Button
ml="auto"
isLoading={formState.isLoading || formState.isValidating || formState.isSubmitting}
isDisabled={!formState.isDirty}
colorScheme="primary"
type="submit"
>
Save Settings
</Button>
</VerticalPageLayout>
);
}