lots of improvements

This commit is contained in:
hzrd149 2023-02-07 17:04:18 -06:00
parent d610c838fc
commit fe108683c8
19 changed files with 144 additions and 146 deletions

View File

@ -14,7 +14,3 @@
- add emoji reaction button
- save relay list as note
- load relays from note
create a subscription manager that takes a "canMerge" function and batches requests
create a template for a cached subscription service
create a template for a cached request service

View File

@ -3,6 +3,7 @@ import { BehaviorSubject } from "rxjs";
export class PubkeySubjectCache<T> {
subjects = new Map<string, BehaviorSubject<T | null>>();
relays = new Map<string, Set<string>>();
dirty = false;
hasSubject(pubkey: string) {
return this.subjects.has(pubkey);
@ -12,6 +13,7 @@ export class PubkeySubjectCache<T> {
if (!subject) {
subject = new BehaviorSubject<T | null>(null);
this.subjects.set(pubkey, subject);
this.dirty = true;
}
return subject;
}
@ -19,6 +21,7 @@ export class PubkeySubjectCache<T> {
const set = this.relays.get(pubkey) ?? new Set();
for (const url of relays) set.add(url);
this.relays.set(pubkey, set);
this.dirty = true;
}
getAllPubkeysMissingData(include: string[] = []) {
@ -44,6 +47,7 @@ export class PubkeySubjectCache<T> {
this.subjects.delete(key);
this.relays.delete(key);
prunedKeys.push(key);
this.dirty = true;
}
}
return prunedKeys;

View File

@ -3,11 +3,13 @@ import { createIcon, IconProps } from "@chakra-ui/icons";
import astralIcon from "./icons/astral.png";
import nostrGuruIcon from "./icons/nostr-guru.jpg";
import brbIcon from "./icons/brb.png";
import snortSocialIcon from "./icons/snort-social.png";
export const IMAGE_ICONS = {
astralIcon,
nostrGuruIcon,
brbIcon,
snortSocialIcon,
};
const defaultProps: IconProps = { fontSize: "1.2em" };

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

@ -23,6 +23,7 @@ import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { PostContents } from "./post-contents";
import { PostMenu } from "./post-menu";
import { PostCC } from "./post-cc";
import { isReply } from "../../helpers/nostr-event";
export type PostProps = {
event: NostrEvent;
@ -40,19 +41,14 @@ export const Post = React.memo(({ event }: PostProps) => {
<Box>
<Heading size="sm" display="inline">
<Link
to={`/u/${normalizeToBech32(
event.pubkey,
Bech32Prefix.Pubkey
)}`}
>
<Link to={`/u/${normalizeToBech32(event.pubkey, Bech32Prefix.Pubkey)}`}>
{getUserDisplayName(metadata, event.pubkey)}
</Link>
</Heading>
<Text display="inline" ml="2">
{moment(event.created_at * 1000).fromNow()}
</Text>
<PostCC event={event} />
{isReply(event) && <PostCC event={event} />}
</Box>
</Flex>
<PostMenu event={event} />
@ -70,9 +66,7 @@ export const Post = React.memo(({ event }: PostProps) => {
<Button
size="sm"
variant="link"
onClick={() =>
navigate(`/e/${normalizeToBech32(event.id, Bech32Prefix.Note)}`)
}
onClick={() => navigate(`/e/${normalizeToBech32(event.id, Bech32Prefix.Note)}`)}
>
Replies
</Button>

View File

@ -49,7 +49,12 @@ export const PostMenu = ({ event }: { event: NostrEvent }) => {
>
Open in BRB
</MenuItem>
<MenuItem as="a" href={`https://snort.social/e/${noteId}`} target="_blank">
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.snortSocialIcon} size="xs" />}
href={`https://snort.social/e/${noteId}`}
target="_blank"
>
Open in snort.social
</MenuItem>
{noteId && (

View File

@ -1,20 +1,27 @@
import { useRef } from "react";
import { useMount, useUnmount } from "react-use";
import { useDeepCompareEffect, useUnmount } from "react-use";
import { NostrSubscription } from "../classes/nostr-subscription";
import settings from "../services/settings";
import { NostrQuery } from "../types/nostr-query";
import useSubject from "./use-subject";
/** @deprecated */
export function useSubscription(
urls: string[],
query: NostrQuery,
name?: string
) {
type Options = {
name?: string;
enabled?: boolean;
};
export function useSubscription(query: NostrQuery, opts?: Options) {
const relays = useSubject(settings.relays);
const sub = useRef<NostrSubscription | null>(null);
sub.current = sub.current || new NostrSubscription(urls, query, name);
sub.current = sub.current || new NostrSubscription(relays, undefined, opts?.name);
useMount(() => {
if (sub.current) sub.current.open();
});
useDeepCompareEffect(() => {
if (sub.current) {
sub.current.update(query);
if (opts?.enabled ?? true) sub.current.open();
else sub.current.close();
}
}, [query]);
useUnmount(() => {
if (sub.current) {
sub.current.close();

View File

@ -0,0 +1,39 @@
import moment from "moment";
import { useCallback, useEffect, useState } from "react";
import { NostrEvent } from "../types/nostr-event";
import { useEventDir } from "./use-event-dir";
import { useSubscription } from "./use-subscription";
export function useUserTimeline(pubkey: string, filter?: (event: NostrEvent) => boolean) {
const [until, setUntil] = useState<number | undefined>(undefined);
const [since, setSince] = useState<number>(moment().subtract(1, "day").startOf("day").unix());
const sub = useSubscription({ authors: [pubkey], kinds: [1], since, until }, { name: `${pubkey} posts` });
const eventDir = useEventDir(sub, filter);
const reset = useCallback(() => {
setUntil(undefined);
setSince(moment().subtract(1, "day").startOf("day").unix());
eventDir.reset();
}, [eventDir.reset, setUntil, setSince]);
// clear events when pubkey changes
useEffect(() => reset(), [pubkey]);
const timeline = Object.values(eventDir.events).sort((a, b) => b.created_at - a.created_at);
const more = useCallback(
(days: number) => {
setUntil(since);
setSince(moment.unix(since).add(days, "days").unix());
},
[setSince, setUntil, since]
);
return {
timeline,
reset,
more,
};
}

View File

@ -54,6 +54,8 @@ function requestContacts(pubkey: string, relays: string[] = [], alwaysRequest =
}
function flushRequests() {
if (!subjects.dirty) return;
const pubkeys = new Set<string>();
const relays = new Set<string>();
@ -73,6 +75,7 @@ function flushRequests() {
if (subscription.state !== NostrSubscription.OPEN) {
subscription.open();
}
subjects.dirty = false;
}
function receiveEvent(event: NostrEvent) {

View File

@ -28,16 +28,18 @@ function requestFollowers(pubkey: string, relays: string[] = [], alwaysRequest =
if (relays.length) subjects.addRelays(pubkey, relays);
if (alwaysRequest) forceRequestedKeys.add(pubkey);
db.getAllKeysFromIndex("user-contacts", "contacts", pubkey).then((cached) => {
mergeNext(subject, cached);
});
if (alwaysRequest) forceRequestedKeys.add(pubkey);
return subject;
}
function flushRequests() {
if (!subjects.dirty) return;
const pubkeys = new Set<string>();
const relays = new Set<string>();
@ -57,6 +59,7 @@ function flushRequests() {
if (subscription.state !== NostrSubscription.OPEN) {
subscription.open();
}
subjects.dirty = false;
}
function receiveEvent(event: NostrEvent) {

View File

@ -39,6 +39,8 @@ function parseMetadata(event: NostrEvent): Metadata | undefined {
}
function flushRequests() {
if (!subjects.dirty) return;
const pubkeys = new Set<string>();
const relays = new Set<string>();
@ -58,6 +60,7 @@ function flushRequests() {
if (subscription.state !== NostrSubscription.OPEN) {
subscription.open();
}
subjects.dirty = false;
}
subscription.onEvent.subscribe((event) => {

View File

@ -25,6 +25,7 @@ export type Kind0ParsedContent = {
display_name?: string;
about?: string;
picture?: string;
banner?: string;
};
export function isETag(tag: Tag): tag is ETag {

View File

@ -1,59 +1,33 @@
import {
Flex,
SkeletonText,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from "@chakra-ui/react";
import { Flex, SkeletonText, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import { useSubscription } from "../../hooks/use-subscription";
import { Post } from "../../components/post";
import moment from "moment/moment";
import settings from "../../services/settings";
import useSubject from "../../hooks/use-subject";
import { useEventDir } from "../../hooks/use-event-dir";
import { NostrSubscription } from "../../classes/nostr-subscription";
import { isPost, isReply } from "../../helpers/nostr-event";
import { NostrEvent } from "../../types/nostr-event";
const PostsTimeline = ({ sub }: { sub: NostrSubscription }) => {
const { events } = useEventDir(sub, isPost);
const timeline = Object.values(events).sort(
(a, b) => b.created_at - a.created_at
);
const PostsTimeline = ({ timeline }: { timeline: NostrEvent[] }) => {
if (timeline.length === 0) {
return <SkeletonText />;
}
if (timeline.length > 20) timeline.length = 20;
return (
<Flex direction="column" gap="2">
{timeline.map((event) => (
{timeline.filter(isPost).map((event) => (
<Post key={event.id} event={event} />
))}
</Flex>
);
};
const RepliesTimeline = ({ sub }: { sub: NostrSubscription }) => {
const { events } = useEventDir(sub, isReply);
const timeline = Object.values(events).sort(
(a, b) => b.created_at - a.created_at
);
const RepliesTimeline = ({ timeline }: { timeline: NostrEvent[] }) => {
if (timeline.length === 0) {
return <SkeletonText />;
}
if (timeline.length > 20) timeline.length = 20;
return (
<Flex direction="column" gap="2">
{timeline.map((event) => (
{timeline.filter(isReply).map((event) => (
<Post key={event.id} event={event} />
))}
</Flex>
@ -61,32 +35,26 @@ const RepliesTimeline = ({ sub }: { sub: NostrSubscription }) => {
};
export const GlobalView = () => {
const relays = useSubject(settings.relays);
const sub = useSubscription(
relays,
{ kinds: [1], limit: 10, since: moment().startOf("day").valueOf() / 1000 },
"global-events"
{ name: "global-events" }
);
const { events } = useEventDir(sub);
const timeline = Object.values(events).sort((a, b) => b.created_at - a.created_at);
return (
<Tabs
display="flex"
flexDirection="column"
flexGrow="1"
overflow="hidden"
isLazy
>
<Tabs display="flex" flexDirection="column" flexGrow="1" overflow="hidden" isLazy>
<TabList>
<Tab>Notes</Tab>
<Tab>Posts</Tab>
<Tab>Replies</Tab>
</TabList>
<TabPanels overflow="auto" height="100%">
<TabPanel pr={0} pl={0}>
<PostsTimeline sub={sub} />
<PostsTimeline timeline={timeline} />
</TabPanel>
<TabPanel pr={0} pl={0}>
<RepliesTimeline sub={sub} />
<RepliesTimeline timeline={timeline} />
</TabPanel>
</TabPanels>
</Tabs>

View File

@ -39,7 +39,6 @@ function useExtendedContacts(pubkey: string) {
}
export const DiscoverTab = () => {
const relays = useSubject(settings.relays);
const pubkey = useSubject(identity.pubkey);
const contactsOfContacts = useExtendedContacts(pubkey);
@ -48,13 +47,12 @@ export const DiscoverTab = () => {
const [after, setAfter] = useState(moment());
const sub = useSubscription(
relays,
{
authors: contactsOfContacts,
kinds: [1],
since: since.unix(),
},
"home-discover"
{ name: "home-discover", enabled: contactsOfContacts.length > 0 }
);
const { events } = useEventDir(sub);

View File

@ -10,7 +10,6 @@ import identity from "../../services/identity";
import settings from "../../services/settings";
export const FollowingTab = () => {
const relays = useSubject(settings.relays);
const pubkey = useSubject(identity.pubkey);
const contacts = useUserContacts(pubkey);
@ -19,13 +18,12 @@ export const FollowingTab = () => {
const following = contacts?.contacts || [];
const sub = useSubscription(
relays,
{
authors: following,
kinds: [1],
since: since.unix(),
},
"home-following"
{ name: "home-following", enabled: following.length > 0 }
);
const { events } = useEventDir(sub);

View File

@ -13,6 +13,7 @@ import {
Tabs,
Text,
Box,
Image,
} from "@chakra-ui/react";
import { useParams } from "react-router-dom";
import { UserPostsTab } from "./posts";
@ -27,6 +28,7 @@ import { Page } from "../../components/page";
import { UserProfileMenu } from "./user-profile-menu";
import { UserFollowersTab } from "./followers";
import { useUserFollowers } from "../../hooks/use-user-followers";
import { UserRepliesTab } from "./replies";
export const UserPage = () => {
const params = useParams();
@ -64,6 +66,7 @@ export const UserView = ({ pubkey }: UserViewProps) => {
return (
<Flex direction="column" alignItems="stretch" gap="2" overflow="hidden" height="100%">
{/* {metadata?.banner && <Image src={metadata.banner} />} */}
<Flex gap="4" padding="2">
<UserAvatar pubkey={pubkey} size={isMobile ? "md" : "xl"} />
<Flex direction="column" gap={isMobile ? 0 : 2}>
@ -76,7 +79,8 @@ export const UserView = ({ pubkey }: UserViewProps) => {
</Flex>
<Tabs display="flex" flexDirection="column" flexGrow="1" overflow="hidden" isLazy>
<TabList>
<Tab>Notes</Tab>
<Tab>Posts</Tab>
<Tab>Replies</Tab>
<Tab>Followers ({followers?.length})</Tab>
<Tab>Following</Tab>
<Tab>Relays</Tab>
@ -86,6 +90,9 @@ export const UserView = ({ pubkey }: UserViewProps) => {
<TabPanel pr={0} pl={0}>
<UserPostsTab pubkey={pubkey} />
</TabPanel>
<TabPanel pr={0} pl={0}>
<UserRepliesTab pubkey={pubkey} />
</TabPanel>
<TabPanel>
<UserFollowersTab pubkey={pubkey} />
</TabPanel>

View File

@ -1,40 +1,19 @@
import { useEffect } from "react";
import { Flex, SkeletonText } from "@chakra-ui/react";
import { useSubscription } from "../../hooks/use-subscription";
import { Button, Flex, Spinner } from "@chakra-ui/react";
import { Post } from "../../components/post";
import settings from "../../services/settings";
import useSubject from "../../hooks/use-subject";
import { useEventDir } from "../../hooks/use-event-dir";
import { isPost } from "../../helpers/nostr-event";
import { useUserTimeline } from "../../hooks/use-user-timeline";
export const UserPostsTab = ({ pubkey }: { pubkey: string }) => {
const relays = useSubject(settings.relays);
const sub = useSubscription(
relays,
{ authors: [pubkey], kinds: [1] },
`${pubkey} posts`
);
const { events, reset } = useEventDir(sub);
// clear events when pubkey changes
useEffect(() => reset(), [pubkey]);
const timeline = Object.values(events).sort(
(a, b) => b.created_at - a.created_at
);
if (timeline.length === 0) {
return <SkeletonText />;
}
if (timeline.length > 30) timeline.length = 30;
const { timeline, more } = useUserTimeline(pubkey, isPost);
return (
<Flex direction="column" gap="2" pr="2" pl="2">
{timeline.map((event) => (
<Post key={event.id} event={event} />
))}
{timeline.length > 0 ? (
timeline.map((event) => <Post key={event.id} event={event} />)
) : (
<Spinner ml="auto" mr="auto" mt="8" mb="8" />
)}
<Button onClick={() => more(1)}>Load More</Button>
</Flex>
);
};

View File

@ -1,43 +1,21 @@
import { useEffect } from "react";
import { Flex, SkeletonText } from "@chakra-ui/react";
import { useSubscription } from "../../hooks/use-subscription";
import { Button, Flex, SkeletonText } from "@chakra-ui/react";
import { Post } from "../../components/post";
import settings from "../../services/settings";
import useSubject from "../../hooks/use-subject";
import { useEventDir } from "../../hooks/use-event-dir";
import { isReply } from "../../helpers/nostr-event";
import { useUserTimeline } from "../../hooks/use-user-timeline";
export const UserRepliesTab = ({ pubkey }: { pubkey: string }) => {
const relays = useSubject(settings.relays);
const sub = useSubscription(
relays,
{ authors: [pubkey], kinds: [1] },
`${pubkey} posts`
);
const { events, reset } = useEventDir(
sub,
(event) => !!event.tags.find((t) => t[0] === "e")
);
// clear events when pubkey changes
useEffect(() => reset(), [pubkey]);
const timeline = Object.values(events).sort(
(a, b) => b.created_at - a.created_at
);
const { timeline, more } = useUserTimeline(pubkey, isReply);
if (timeline.length === 0) {
return <SkeletonText />;
}
if (timeline.length > 30) timeline.length = 30;
return (
<Flex direction="column" gap="2" pr="2" pl="2">
{timeline.map((event) => (
<Post key={event.id} event={event} />
))}
<Button onClick={() => more(1)}>Load More</Button>
</Flex>
);
};

View File

@ -1,18 +1,21 @@
import { Avatar, MenuItem } from "@chakra-ui/react";
import { MenuIconButton } from "../../components/menu-icon-button";
import { IMAGE_ICONS } from "../../components/icons";
import { ClipboardIcon, IMAGE_ICONS } from "../../components/icons";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
import { useCopyToClipboard } from "react-use";
import { truncatedId } from "../../helpers/nostr-event";
export const UserProfileMenu = ({ pubkey }: { pubkey: string }) => {
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
return (
<MenuIconButton>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.nostrGuruIcon} size="xs" />}
href={`https://www.nostr.guru/p/${pubkey}`}
href={`https://www.nostr.guru/p/${npub}`}
target="_blank"
>
Open in Nostr.guru
@ -20,10 +23,7 @@ export const UserProfileMenu = ({ pubkey }: { pubkey: string }) => {
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.astralIcon} size="xs" />}
href={`https://astral.ninja/${normalizeToBech32(
pubkey,
Bech32Prefix.Pubkey
)}`}
href={`https://astral.ninja/${npub}`}
target="_blank"
>
Open in astral
@ -31,11 +31,24 @@ export const UserProfileMenu = ({ pubkey }: { pubkey: string }) => {
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.brbIcon} size="xs" />}
href={`https://brb.io/u/${pubkey}`}
href={`https://brb.io/u/${npub}`}
target="_blank"
>
Open in BRB
</MenuItem>
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.snortSocialIcon} size="xs" />}
href={`https://snort.social/p/${npub}`}
target="_blank"
>
Open in snort.social
</MenuItem>
{npub && (
<MenuItem onClick={() => copyToClipboard(npub)} icon={<ClipboardIcon />}>
Copy {truncatedId(npub)}
</MenuItem>
)}
</MenuIconButton>
);
};