mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 12:49:29 +02:00
notifications v6
This commit is contained in:
parent
8deeec45ce
commit
a5f04d802b
@ -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) {
|
||||
|
13
src/hooks/use-read-status.ts
Normal file
13
src/hooks/use-read-status.ts
Normal 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;
|
||||
}
|
@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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
106
src/services/read-status.ts
Normal 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;
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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} />;
|
||||
|
5
src/views/notifications/focused-context.ts
Normal file
5
src/views/notifications/focused-context.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
const FocusedContext = createContext({ id: "", focus: (id: string) => {} });
|
||||
|
||||
export default FocusedContext;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user