mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
Merge branch 'next'
This commit is contained in:
commit
54263f49a2
5
.changeset/five-news-dress.md
Normal file
5
.changeset/five-news-dress.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Show lightning address on about page
|
5
.changeset/rotten-donuts-reflect.md
Normal file
5
.changeset/rotten-donuts-reflect.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add people list context and selector
|
5
.changeset/selfish-years-walk.md
Normal file
5
.changeset/selfish-years-walk.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Use timeline loader for followers view
|
5
.changeset/soft-walls-wink.md
Normal file
5
.changeset/soft-walls-wink.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add expiration to cached metadata events
|
5
.changeset/tender-lions-sort.md
Normal file
5
.changeset/tender-lions-sort.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Rebuild underlying event requester classes
|
@ -34,8 +34,6 @@
|
||||
"react-router-dom": "^6.14.1",
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"react-window": "^1.8.9",
|
||||
"webln": "^0.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -45,7 +43,6 @@
|
||||
"@types/identicon.js": "^2.3.1",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"cypress": "^12.16.0",
|
||||
"prettier": "^2.8.8",
|
||||
|
@ -1,42 +0,0 @@
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { PubkeyEventRequester } from "./pubkey-event-requester";
|
||||
|
||||
export class CachedPubkeyEventRequester extends PubkeyEventRequester {
|
||||
private readCacheDedupe = new Map<string, Promise<NostrEvent | undefined>>();
|
||||
async readCache(pubkey: string): Promise<NostrEvent | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
async writeCache(pubkey: string, event: NostrEvent): Promise<any> {}
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
const sub = this.getSubject(event.pubkey);
|
||||
if (!sub.value || event.created_at > sub.value.created_at) {
|
||||
this.writeCache(event.pubkey, event);
|
||||
}
|
||||
super.handleEvent(event);
|
||||
}
|
||||
|
||||
requestEvent(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
const sub = this.getSubject(pubkey);
|
||||
|
||||
if (!sub.value) {
|
||||
// only call this.readCache once per pubkey
|
||||
if (!this.readCacheDedupe.has(pubkey)) {
|
||||
const promise = this.readCacheDedupe.get(pubkey) || this.readCache(pubkey);
|
||||
this.readCacheDedupe.set(pubkey, promise);
|
||||
|
||||
promise.then((cached) => {
|
||||
this.readCacheDedupe.delete(pubkey);
|
||||
|
||||
if (cached) this.handleEvent(cached);
|
||||
|
||||
if (!sub.value || alwaysRequest) super.requestEvent(pubkey, relays);
|
||||
});
|
||||
}
|
||||
} else if (alwaysRequest) {
|
||||
super.requestEvent(pubkey, relays);
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
}
|
@ -1,172 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import debug, { Debugger } from "debug";
|
||||
import { NostrSubscription } from "./nostr-subscription";
|
||||
import { SuperMap } from "./super-map";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import Subject from "./subject";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { nameOrPubkey } from "../helpers/debug";
|
||||
|
||||
type pubkey = string;
|
||||
type relay = string;
|
||||
|
||||
class PubkeyEventRequestSubscription {
|
||||
private subscription: NostrSubscription;
|
||||
private kind: number;
|
||||
private dTag?: string;
|
||||
|
||||
private subjects = new SuperMap<pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||
|
||||
private requestNext = new Set<pubkey>();
|
||||
|
||||
private requestedPubkeys = new Map<pubkey, Date>();
|
||||
|
||||
log: Debugger;
|
||||
|
||||
constructor(relay: string, kind: number, name?: string, dTag?: string, log?: Debugger) {
|
||||
this.kind = kind;
|
||||
this.dTag = dTag;
|
||||
this.subscription = new NostrSubscription(relay, undefined, name);
|
||||
|
||||
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
|
||||
this.subscription.onEOSE.subscribe(this.handleEOSE.bind(this));
|
||||
|
||||
this.log = log || debug("misc");
|
||||
}
|
||||
|
||||
private handleEvent(event: NostrEvent) {
|
||||
// reject the event if its the wrong kind
|
||||
if (event.kind !== this.kind) return;
|
||||
// reject the event if has the wrong d tag or is missing one
|
||||
if (this.dTag && !event.tags.some((t) => t[0] === "d" && t[1] === this.dTag)) return;
|
||||
|
||||
// remove the pubkey from the waiting list
|
||||
this.requestedPubkeys.delete(event.pubkey);
|
||||
|
||||
const sub = this.subjects.get(event.pubkey);
|
||||
|
||||
const current = sub.value;
|
||||
if (!current || event.created_at > current.created_at) {
|
||||
this.log(`Found newer event for ${nameOrPubkey(event.pubkey)}`);
|
||||
sub.next(event);
|
||||
}
|
||||
}
|
||||
private handleEOSE() {
|
||||
// relays says it has nothing left
|
||||
this.requestedPubkeys.clear();
|
||||
}
|
||||
|
||||
getSubject(pubkey: string) {
|
||||
return this.subjects.get(pubkey);
|
||||
}
|
||||
|
||||
requestEvent(pubkey: string) {
|
||||
const sub = this.subjects.get(pubkey);
|
||||
|
||||
if (!sub.value) {
|
||||
this.log(`Adding ${nameOrPubkey(pubkey)} to queue`);
|
||||
this.requestNext.add(pubkey);
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
update() {
|
||||
let needsUpdate = false;
|
||||
for (const pubkey of this.requestNext) {
|
||||
if (!this.requestedPubkeys.has(pubkey)) {
|
||||
this.requestedPubkeys.set(pubkey, new Date());
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
this.requestNext.clear();
|
||||
|
||||
// prune pubkeys
|
||||
const timeout = dayjs().subtract(1, "minute");
|
||||
for (const [pubkey, date] of this.requestedPubkeys) {
|
||||
if (dayjs(date).isBefore(timeout)) {
|
||||
this.requestedPubkeys.delete(pubkey);
|
||||
needsUpdate = true;
|
||||
this.log(`Request for ${nameOrPubkey(pubkey)} expired`);
|
||||
}
|
||||
}
|
||||
|
||||
// update the subscription
|
||||
if (needsUpdate) {
|
||||
if (this.requestedPubkeys.size > 0) {
|
||||
const query: NostrQuery = { authors: Array.from(this.requestedPubkeys.keys()), kinds: [this.kind] };
|
||||
if (this.dTag) query["#d"] = [this.dTag];
|
||||
|
||||
this.log(`Updating query with ${query.authors?.length} pubkeys`);
|
||||
this.subscription.setQuery(query);
|
||||
|
||||
if (this.subscription.state !== NostrSubscription.OPEN) {
|
||||
this.subscription.open();
|
||||
}
|
||||
} else if (this.subscription.state === NostrSubscription.OPEN) {
|
||||
this.subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PubkeyEventRequester {
|
||||
private kind: number;
|
||||
private name?: string;
|
||||
private dTag?: string;
|
||||
private subjects = new SuperMap<pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||
|
||||
private subscriptions = new SuperMap<relay, PubkeyEventRequestSubscription>(
|
||||
(relay) => new PubkeyEventRequestSubscription(relay, this.kind, this.name, this.dTag, this.log.extend(relay))
|
||||
);
|
||||
|
||||
log: Debugger;
|
||||
|
||||
constructor(kind: number, name?: string, dTag?: string, log?: Debugger) {
|
||||
this.kind = kind;
|
||||
this.name = name;
|
||||
this.dTag = dTag;
|
||||
|
||||
this.log = log || debug("misc");
|
||||
}
|
||||
|
||||
getSubject(pubkey: string) {
|
||||
return this.subjects.get(pubkey);
|
||||
}
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
if (event.kind !== this.kind) return;
|
||||
|
||||
const sub = this.subjects.get(event.pubkey);
|
||||
const current = sub.value;
|
||||
if (!current || event.created_at > current.created_at) {
|
||||
this.log(`New event for ${nameOrPubkey(event.pubkey)}`);
|
||||
sub.next(event);
|
||||
}
|
||||
}
|
||||
|
||||
requestEvent(pubkey: string, relays: string[]) {
|
||||
this.log(`Requesting event for ${nameOrPubkey(pubkey)}`);
|
||||
const sub = this.subjects.get(pubkey);
|
||||
|
||||
for (const relay of relays) {
|
||||
const relaySub = this.subscriptions.get(relay).requestEvent(pubkey);
|
||||
|
||||
sub.connectWithHandler(relaySub, (event, next, current) => {
|
||||
if (event.kind !== this.kind) return;
|
||||
if (!current || event.created_at > current.created_at) {
|
||||
this.log(`Event for ${nameOrPubkey(event.pubkey)} from connection`);
|
||||
next(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
update() {
|
||||
for (const [relay, subscription] of this.subscriptions) {
|
||||
subscription.update();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import dayjs from "dayjs";
|
||||
import { utils } from "nostr-tools";
|
||||
import debug, { Debug, Debugger } from "debug";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
|
||||
import { NostrRequest } from "./nostr-request";
|
||||
import { NostrMultiSubscription } from "./nostr-multi-subscription";
|
||||
import Subject, { PersistentSubject } from "./subject";
|
||||
import { logger } from "../helpers/debug";
|
||||
|
||||
function addToQuery(filter: NostrRequestFilter, query: NostrQuery) {
|
||||
if (Array.isArray(filter)) {
|
||||
@ -23,6 +25,7 @@ class RelayTimelineLoader {
|
||||
blockSize = BLOCK_SIZE;
|
||||
private name?: string;
|
||||
private requestId = 0;
|
||||
private log: Debugger;
|
||||
|
||||
loading = false;
|
||||
events: NostrEvent[] = [];
|
||||
@ -32,17 +35,19 @@ class RelayTimelineLoader {
|
||||
onEvent = new Subject<NostrEvent>();
|
||||
onBlockFinish = new Subject<void>();
|
||||
|
||||
constructor(relay: string, query: NostrRequestFilter, name?: string) {
|
||||
constructor(relay: string, query: NostrRequestFilter, name: string, log?: Debugger) {
|
||||
this.relay = relay;
|
||||
this.query = query;
|
||||
this.name = name;
|
||||
|
||||
this.log = log || logger.extend(name);
|
||||
}
|
||||
|
||||
loadNextBlock() {
|
||||
this.loading = true;
|
||||
let query: NostrRequestFilter = addToQuery(this.query, { limit: this.blockSize });
|
||||
if (this.events[this.events.length - 1]) {
|
||||
query = addToQuery(query, { until: this.events[this.events.length - 1].created_at + 1 });
|
||||
query = addToQuery(query, { until: this.events[this.events.length - 1].created_at - 1 });
|
||||
}
|
||||
|
||||
const request = new NostrRequest([this.relay], undefined, this.name + "-" + this.requestId++);
|
||||
@ -56,6 +61,7 @@ class RelayTimelineLoader {
|
||||
request.onComplete.then(() => {
|
||||
this.loading = false;
|
||||
if (gotEvents === 0) this.complete = true;
|
||||
this.log(`Got ${gotEvents} events`);
|
||||
this.onBlockFinish.next();
|
||||
});
|
||||
|
||||
@ -95,11 +101,15 @@ export class TimelineLoader {
|
||||
loadNextBlockBuffer = 2;
|
||||
eventFilter?: (event: NostrEvent) => boolean;
|
||||
|
||||
private name: string;
|
||||
private log: Debugger;
|
||||
private subscription: NostrMultiSubscription;
|
||||
|
||||
private relayTimelineLoaders = new Map<string, RelayTimelineLoader>();
|
||||
|
||||
constructor(name?: string) {
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
this.log = logger.extend("TimelineLoader:" + name);
|
||||
this.subscription = new NostrMultiSubscription([], undefined, name);
|
||||
this.subscription.onEvent.subscribe(this.handleEvent, this);
|
||||
}
|
||||
@ -121,7 +131,7 @@ export class TimelineLoader {
|
||||
|
||||
for (const relay of this.relays) {
|
||||
if (!this.relayTimelineLoaders.has(relay)) {
|
||||
const loader = new RelayTimelineLoader(relay, this.query, this.subscription.name);
|
||||
const loader = new RelayTimelineLoader(relay, this.query, this.name, this.log.extend(relay));
|
||||
this.relayTimelineLoaders.set(relay, loader);
|
||||
loader.onEvent.subscribe(this.handleEvent, this);
|
||||
loader.onBlockFinish.subscribe(this.updateLoading, this);
|
||||
@ -195,10 +205,13 @@ export class TimelineLoader {
|
||||
}
|
||||
/** @deprecated */
|
||||
loadMore() {
|
||||
let triggeredLoad = false;
|
||||
for (const [relay, loader] of this.relayTimelineLoaders) {
|
||||
if (loader.complete || loader.loading) continue;
|
||||
loader.loadNextBlock();
|
||||
triggeredLoad = true;
|
||||
}
|
||||
if (triggeredLoad) this.updateLoading();
|
||||
}
|
||||
|
||||
private updateLoading() {
|
||||
|
@ -6,14 +6,15 @@ import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import RawValue from "./raw-value";
|
||||
import RawJson from "./raw-json";
|
||||
import { useSharableProfileId } from "../../hooks/use-shareable-profile-id";
|
||||
import userRelaysService from "../../services/user-relays";
|
||||
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
||||
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
export default function UserDebugModal({ pubkey, ...props }: { pubkey: string } & Omit<ModalProps, "children">) {
|
||||
const npub = useMemo(() => normalizeToBech32(pubkey, Bech32Prefix.Pubkey), [pubkey]);
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const nprofile = useSharableProfileId(pubkey);
|
||||
const relays = userRelaysService.requester.getSubject(pubkey).value;
|
||||
const relays = replaceableEventLoaderService.getEvent(Kind.RelayList, pubkey).value;
|
||||
const tipMetadata = useUserLNURLMetadata(pubkey);
|
||||
|
||||
return (
|
||||
|
@ -15,7 +15,15 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<ReloadPrompt mb="2" />
|
||||
<Container size="lg" display="flex" padding="0" gap="4" alignItems="flex-start">
|
||||
{!isMobile && <DesktopSideNav position="sticky" top="0" />}
|
||||
<Flex flexGrow={1} direction="column" w="full" overflowX="hidden" overflowY="visible" pb={isMobile ? "14" : 0}>
|
||||
<Flex
|
||||
flexGrow={1}
|
||||
direction="column"
|
||||
w="full"
|
||||
overflowX="hidden"
|
||||
overflowY="visible"
|
||||
pb={isMobile ? "14" : 0}
|
||||
minH="50vh"
|
||||
>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Flex>
|
||||
{isMobile && (
|
||||
|
@ -0,0 +1,74 @@
|
||||
import { PropsWithChildren, createContext, useContext, useMemo, useState } from "react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||
import { isPTag } from "../../types/nostr-event";
|
||||
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import clientFollowingService from "../../services/client-following";
|
||||
|
||||
export type ListIdentifier = "following" | "global" | string;
|
||||
|
||||
export function useParsedNaddr(naddr?: string) {
|
||||
if (!naddr) return;
|
||||
try {
|
||||
const parsed = nip19.decode(naddr);
|
||||
|
||||
if (parsed.type === "naddr") {
|
||||
return parsed.data;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function useList(naddr?: string) {
|
||||
const parsed = useMemo(() => useParsedNaddr(naddr), [naddr]);
|
||||
const readRelays = useReadRelayUrls(parsed?.relays ?? []);
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (!parsed) return;
|
||||
return replaceableEventLoaderService.requestEvent(readRelays, parsed.kind, parsed.pubkey, parsed.identifier);
|
||||
}, [parsed]);
|
||||
|
||||
return useSubject(sub);
|
||||
}
|
||||
|
||||
export function useListPeople(list: ListIdentifier) {
|
||||
const contacts = useSubject(clientFollowingService.following);
|
||||
|
||||
const listEvent = useList(list);
|
||||
|
||||
if (list === "following") return contacts.map((t) => t[1]);
|
||||
if (listEvent) {
|
||||
return listEvent.tags.filter(isPTag).map((t) => t[1]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export type PeopleListContextType = {
|
||||
list: string;
|
||||
people: string[];
|
||||
setList: (list: string) => void;
|
||||
};
|
||||
const PeopleListContext = createContext<PeopleListContextType>({ list: "following", setList: () => {}, people: [] });
|
||||
|
||||
export function usePeopleListContext() {
|
||||
return useContext(PeopleListContext);
|
||||
}
|
||||
|
||||
export default function PeopleListProvider({ children }: PropsWithChildren) {
|
||||
const account = useCurrentAccount();
|
||||
const [list, setList] = useState(account ? "following" : "global");
|
||||
|
||||
const people = useListPeople(list);
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
people,
|
||||
list,
|
||||
setList,
|
||||
}),
|
||||
[list, setList]
|
||||
);
|
||||
|
||||
return <PeopleListContext.Provider value={context}>{children}</PeopleListContext.Provider>;
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { Select, SelectProps, useDisclosure } from "@chakra-ui/react";
|
||||
import { usePeopleListContext } from "./people-list-provider";
|
||||
|
||||
export default function PeopleListSelection({
|
||||
hideGlobalOption = false,
|
||||
...props
|
||||
}: {
|
||||
hideGlobalOption?: boolean;
|
||||
} & Omit<SelectProps, "value" | "onChange" | "children">) {
|
||||
const { people, list, setList } = usePeopleListContext();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={list}
|
||||
onChange={(e) => {
|
||||
setList(e.target.value);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<option value="following">Following</option>
|
||||
{!hideGlobalOption && <option value="global">Global</option>}
|
||||
</Select>
|
||||
);
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
@ -25,10 +24,10 @@ import { UserAvatar } from "../../user-avatar";
|
||||
import { UserLink } from "../../user-link";
|
||||
import StreamStatusBadge from "../../../views/streams/components/status-badge";
|
||||
import { NoteRelays } from "../../note/note-relays";
|
||||
import { useAsync } from "react-use";
|
||||
|
||||
export default function StreamNote({ event, ...props }: CardProps & { event: NostrEvent }) {
|
||||
const stream = useMemo(() => parseStreamEvent(event), [event]);
|
||||
const { title, image } = stream;
|
||||
const { value: stream, error } = useAsync(async () => parseStreamEvent(event), [event]);
|
||||
|
||||
// if there is a parent intersection observer, register this card
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
@ -36,6 +35,8 @@ export default function StreamNote({ event, ...props }: CardProps & { event: Nos
|
||||
|
||||
const naddr = useEventNaddr(event);
|
||||
|
||||
if (!stream || error) return null;
|
||||
|
||||
return (
|
||||
<Card {...props} ref={ref}>
|
||||
<LinkBox as={CardBody} p="2" display="flex" flexDirection="column" gap="2">
|
||||
@ -47,10 +48,10 @@ export default function StreamNote({ event, ...props }: CardProps & { event: Nos
|
||||
<UserLink pubkey={stream.host} />
|
||||
</Heading>
|
||||
</Flex>
|
||||
{image && <Image src={image} alt={title} borderRadius="lg" maxH="15rem" />}
|
||||
{stream.image && <Image src={stream.image} alt={stream.title} borderRadius="lg" maxH="15rem" />}
|
||||
<Heading size="md">
|
||||
<LinkOverlay as={RouterLink} to={`/streams/${naddr}`}>
|
||||
{title}
|
||||
{stream.title}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
@ -53,7 +53,7 @@ export type LinkEmbedHandler = (link: URL) => JSX.Element | string | null;
|
||||
export function embedUrls(content: EmbedableContent, handlers: LinkEmbedHandler[]) {
|
||||
return embedJSX(content, {
|
||||
name: "embedUrls",
|
||||
regexp: /https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,12})(\/[\+~%\/\.\w\-_]*)?([\?#][^\s]+)?/i,
|
||||
regexp: /https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,12})(\/[\+~%\/\.\w\-_@]*)?([\?#][^\s]+)?/i,
|
||||
render: (match) => {
|
||||
try {
|
||||
const url = new URL(match[0]);
|
||||
|
@ -1,13 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import userFollowersService from "../services/user-followers";
|
||||
import useSubject from "./use-subject";
|
||||
|
||||
export function useUserFollowers(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
const subject = useMemo(
|
||||
() => userFollowersService.requestFollowers(pubkey, relays, alwaysRequest),
|
||||
[pubkey, alwaysRequest]
|
||||
);
|
||||
const followers = useSubject(subject) ?? undefined;
|
||||
|
||||
return followers;
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
import { openDB, deleteDB } from "idb";
|
||||
|
||||
import { IDBPDatabase } from "idb";
|
||||
import { SchemaV1, SchemaV2 } from "./schema";
|
||||
import { SchemaV1, SchemaV2, SchemaV3 } from "./schema";
|
||||
|
||||
const dbName = "storage";
|
||||
const version = 2;
|
||||
const db = await openDB<SchemaV2>(dbName, version, {
|
||||
const version = 3;
|
||||
const db = await openDB<SchemaV3>(dbName, version, {
|
||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
||||
if (oldVersion < 1) {
|
||||
const v0 = db as unknown as IDBPDatabase<SchemaV1>;
|
||||
@ -56,14 +56,29 @@ const db = await openDB<SchemaV2>(dbName, version, {
|
||||
});
|
||||
settings.createIndex("created_at", "created_at");
|
||||
}
|
||||
|
||||
if (oldVersion < 3) {
|
||||
const v2 = db as unknown as IDBPDatabase<SchemaV2>;
|
||||
const v3 = db as unknown as IDBPDatabase<SchemaV3>;
|
||||
|
||||
// rename the old event caches
|
||||
v3.deleteObjectStore("userMetadata");
|
||||
v3.deleteObjectStore("userContacts");
|
||||
v3.deleteObjectStore("userRelays");
|
||||
v3.deleteObjectStore("settings");
|
||||
|
||||
// create new replaceable event object store
|
||||
const settings = v3.createObjectStore("replaceableEvents", {
|
||||
keyPath: "addr",
|
||||
});
|
||||
settings.createIndex("created", "created");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export async function clearCacheData() {
|
||||
await db.clear("userMetadata");
|
||||
await db.clear("userContacts");
|
||||
await db.clear("replaceableEvents");
|
||||
await db.clear("userFollows");
|
||||
await db.clear("userRelays");
|
||||
await db.clear("relayInfo");
|
||||
await db.clear("dnsIdentifiers");
|
||||
await db.clear("relayScoreboardStats");
|
||||
|
@ -61,3 +61,19 @@ export interface SchemaV2 extends SchemaV1 {
|
||||
value: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchemaV3 {
|
||||
replaceableEvents: {
|
||||
key: string;
|
||||
value: {
|
||||
addr: string;
|
||||
created: number;
|
||||
event: NostrEvent;
|
||||
};
|
||||
};
|
||||
userFollows: SchemaV2["userFollows"];
|
||||
dnsIdentifiers: SchemaV2["dnsIdentifiers"];
|
||||
relayInfo: SchemaV2["relayInfo"];
|
||||
relayScoreboardStats: SchemaV2["relayScoreboardStats"];
|
||||
misc: SchemaV2["misc"];
|
||||
}
|
||||
|
254
src/services/replaceable-event-requester.ts
Normal file
254
src/services/replaceable-event-requester.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import dayjs from "dayjs";
|
||||
import debug, { Debugger } from "debug";
|
||||
import { NostrSubscription } from "../classes/nostr-subscription";
|
||||
import { SuperMap } from "../classes/super-map";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import Subject from "../classes/subject";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { logger, nameOrPubkey } from "../helpers/debug";
|
||||
import db from "./db";
|
||||
|
||||
type Pubkey = string;
|
||||
type Relay = string;
|
||||
|
||||
export function getReadableAddr(kind: number, pubkey: string, d?: string) {
|
||||
return `${kind}:${nameOrPubkey(pubkey)}${d ? ":" + d : ""}`;
|
||||
}
|
||||
export function getAddr(kind: number, pubkey: string, d?: string) {
|
||||
return `${kind}:${pubkey}${d ? ":" + d : ""}`;
|
||||
}
|
||||
|
||||
class ReplaceableEventRelayLoader {
|
||||
private subscription: NostrSubscription;
|
||||
private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||
|
||||
private requestNext = new Set<string>();
|
||||
private requested = new Map<string, Date>();
|
||||
|
||||
log: Debugger;
|
||||
|
||||
constructor(relay: string, log?: Debugger) {
|
||||
this.subscription = new NostrSubscription(relay, undefined, `replaceable-event-loader`);
|
||||
|
||||
this.subscription.onEvent.subscribe(this.handleEvent.bind(this));
|
||||
this.subscription.onEOSE.subscribe(this.handleEOSE.bind(this));
|
||||
|
||||
this.log = log || debug("misc");
|
||||
}
|
||||
|
||||
private handleEvent(event: NostrEvent) {
|
||||
const d = event.tags.find((t) => t[0] === "d" && t[1])?.[1];
|
||||
const addr = getAddr(event.kind, event.pubkey, d);
|
||||
|
||||
// remove the pubkey from the waiting list
|
||||
this.requested.delete(addr);
|
||||
|
||||
const sub = this.events.get(addr);
|
||||
|
||||
const current = sub.value;
|
||||
if (!current || event.created_at > current.created_at) {
|
||||
sub.next(event);
|
||||
}
|
||||
}
|
||||
private handleEOSE() {
|
||||
// relays says it has nothing left
|
||||
this.requested.clear();
|
||||
}
|
||||
|
||||
getEvent(kind: number, pubkey: string, d?: string) {
|
||||
return this.events.get(getAddr(kind, pubkey, d));
|
||||
}
|
||||
|
||||
requestEvent(kind: number, pubkey: string, d?: string) {
|
||||
const addr = getAddr(kind, pubkey, d);
|
||||
const event = this.events.get(addr);
|
||||
|
||||
if (!event.value) {
|
||||
this.requestNext.add(addr);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
update() {
|
||||
let needsUpdate = false;
|
||||
for (const addr of this.requestNext) {
|
||||
if (!this.requested.has(addr)) {
|
||||
this.requested.set(addr, new Date());
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
this.requestNext.clear();
|
||||
|
||||
// prune requests
|
||||
const timeout = dayjs().subtract(1, "minute");
|
||||
for (const [addr, date] of this.requested) {
|
||||
if (dayjs(date).isBefore(timeout)) {
|
||||
this.requested.delete(addr);
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// update the subscription
|
||||
if (needsUpdate) {
|
||||
if (this.requested.size > 0) {
|
||||
const filters: Record<number, NostrQuery> = {};
|
||||
|
||||
for (const [addr] of this.requested) {
|
||||
const [kindStr, pubkey, d] = addr.split(":") as [string, string] | [string, string, string];
|
||||
const kind = parseInt(kindStr);
|
||||
filters[kind] = filters[kind] || { kinds: [kind] };
|
||||
|
||||
const arr = (filters[kind].authors = filters[kind].authors || []);
|
||||
arr.push(pubkey);
|
||||
|
||||
if (d) {
|
||||
const arr = (filters[kind]["#d"] = filters[kind]["#d"] || []);
|
||||
arr.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
const query = Array.from(Object.values(filters));
|
||||
|
||||
this.log(
|
||||
`Updating query`,
|
||||
Array.from(Object.keys(filters))
|
||||
.map((kind: string) => `kind ${kind}: ${filters[parseInt(kind)].authors?.length}`)
|
||||
.join(", ")
|
||||
);
|
||||
this.subscription.setQuery(query);
|
||||
|
||||
if (this.subscription.state !== NostrSubscription.OPEN) {
|
||||
this.subscription.open();
|
||||
}
|
||||
} else if (this.subscription.state === NostrSubscription.OPEN) {
|
||||
this.subscription.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReplaceableEventLoaderService {
|
||||
private events = new SuperMap<Pubkey, Subject<NostrEvent>>(() => new Subject<NostrEvent>());
|
||||
|
||||
private loaders = new SuperMap<Relay, ReplaceableEventRelayLoader>(
|
||||
(relay) => new ReplaceableEventRelayLoader(relay, this.log.extend(relay))
|
||||
);
|
||||
|
||||
log = logger.extend("ReplaceableEventLoader");
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
const d = event.tags.find((t) => t[0] === "d" && t[1])?.[1];
|
||||
const addr = getAddr(event.kind, event.pubkey, d);
|
||||
|
||||
const sub = this.events.get(addr);
|
||||
const current = sub.value;
|
||||
if (!current || event.created_at > current.created_at) {
|
||||
sub.next(event);
|
||||
this.saveToCache(addr, event);
|
||||
}
|
||||
}
|
||||
|
||||
getEvent(kind: number, pubkey: string, d?: string) {
|
||||
return this.events.get(getAddr(kind, pubkey, d));
|
||||
}
|
||||
|
||||
private loadCacheDedupe = new Map<string, Promise<boolean>>();
|
||||
private loadFromCache(addr: string) {
|
||||
const dedupe = this.loadCacheDedupe.get(addr);
|
||||
if (dedupe) return dedupe;
|
||||
|
||||
const promise = db.get("replaceableEvents", addr).then((cached) => {
|
||||
this.loadCacheDedupe.delete(addr);
|
||||
if (cached?.event) {
|
||||
this.handleEvent(cached.event);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.loadCacheDedupe.set(addr, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
private async saveToCache(addr: string, event: NostrEvent) {
|
||||
await db.put("replaceableEvents", { addr, event, created: dayjs().unix() });
|
||||
}
|
||||
|
||||
async pruneCache() {
|
||||
const keys = await db.getAllKeysFromIndex(
|
||||
"replaceableEvents",
|
||||
"created",
|
||||
IDBKeyRange.upperBound(dayjs().subtract(1, "day").unix())
|
||||
);
|
||||
|
||||
this.log(`Pruning ${keys.length} events`);
|
||||
|
||||
const transaction = db.transaction("replaceableEvents", "readwrite");
|
||||
for (const key of keys) {
|
||||
transaction.store.delete(key);
|
||||
}
|
||||
await transaction.commit();
|
||||
}
|
||||
|
||||
private requestEventFromRelays(relays: string[], kind: number, pubkey: string, d?: string) {
|
||||
const addr = getAddr(kind, pubkey, d);
|
||||
const sub = this.events.get(addr);
|
||||
|
||||
for (const relay of relays) {
|
||||
const request = this.loaders.get(relay).requestEvent(kind, pubkey, d);
|
||||
|
||||
sub.connectWithHandler(request, (event, next, current) => {
|
||||
if (!current || event.created_at > current.created_at) {
|
||||
next(event);
|
||||
this.saveToCache(addr, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
requestEvent(relays: string[], kind: number, pubkey: string, d?: string, alwaysRequest = false) {
|
||||
const addr = getAddr(kind, pubkey, d);
|
||||
const sub = this.events.get(addr);
|
||||
|
||||
if (!sub.value) {
|
||||
this.loadFromCache(addr).then((loaded) => {
|
||||
if (!loaded) {
|
||||
this.requestEventFromRelays(relays, kind, pubkey, d);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (alwaysRequest) {
|
||||
this.requestEventFromRelays(relays, kind, pubkey, d);
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
update() {
|
||||
for (const [relay, loader] of this.loaders) {
|
||||
loader.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const replaceableEventLoaderService = new ReplaceableEventLoaderService();
|
||||
|
||||
replaceableEventLoaderService.pruneCache();
|
||||
|
||||
setInterval(() => {
|
||||
replaceableEventLoaderService.update();
|
||||
}, 1000 * 2);
|
||||
setInterval(() => {
|
||||
replaceableEventLoaderService.pruneCache();
|
||||
}, 1000 * 60 * 60);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
//@ts-ignore
|
||||
window.replaceableEventLoaderService = replaceableEventLoaderService;
|
||||
}
|
||||
|
||||
export default replaceableEventLoaderService;
|
@ -1,45 +1,30 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import db from "../db";
|
||||
import { logger } from "../../helpers/debug";
|
||||
|
||||
import { SuperMap } from "../../classes/super-map";
|
||||
import { PersistentSubject } from "../../classes/subject";
|
||||
import { CachedPubkeyEventRequester } from "../../classes/cached-pubkey-event-requester";
|
||||
import { AppSettings, defaultSettings, parseAppSettings } from "./migrations";
|
||||
import replaceableEventLoaderService from "../replaceable-event-requester";
|
||||
|
||||
const DTAG = "nostrudel-settings";
|
||||
|
||||
class UserAppSettings {
|
||||
requester: CachedPubkeyEventRequester;
|
||||
log = logger.extend("UserAppSettings");
|
||||
|
||||
constructor() {
|
||||
this.requester = new CachedPubkeyEventRequester(30078, "user-app-data", DTAG, this.log.extend("requester"));
|
||||
this.requester.readCache = (pubkey) => db.get("settings", pubkey);
|
||||
this.requester.writeCache = (pubkey, event) => db.put("settings", event);
|
||||
}
|
||||
|
||||
private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>(
|
||||
(pubkey) => new PersistentSubject<AppSettings>(defaultSettings)
|
||||
() => new PersistentSubject<AppSettings>(defaultSettings)
|
||||
);
|
||||
getSubject(pubkey: string) {
|
||||
return this.parsedSubjects.get(pubkey);
|
||||
}
|
||||
requestAppSettings(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
const sub = this.parsedSubjects.get(pubkey);
|
||||
const requestSub = this.requester.requestEvent(pubkey, relays, alwaysRequest);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(relays, 30078, pubkey, DTAG, alwaysRequest);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseAppSettings(event)));
|
||||
return sub;
|
||||
}
|
||||
|
||||
receiveEvent(event: NostrEvent) {
|
||||
this.requester.handleEvent(event);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.requester.update();
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
|
||||
buildAppSettingsEvent(settings: AppSettings): DraftNostrEvent {
|
||||
@ -54,10 +39,6 @@ class UserAppSettings {
|
||||
|
||||
const userAppSettings = new UserAppSettings();
|
||||
|
||||
setInterval(() => {
|
||||
userAppSettings.update();
|
||||
}, 1000 * 2);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.userAppSettings = userAppSettings;
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { isPTag, NostrEvent } from "../types/nostr-event";
|
||||
import { safeJson } from "../helpers/parse";
|
||||
import db from "./db";
|
||||
import { CachedPubkeyEventRequester } from "../classes/cached-pubkey-event-requester";
|
||||
import { SuperMap } from "../classes/super-map";
|
||||
import Subject from "../classes/subject";
|
||||
import { RelayConfig, RelayMode } from "../classes/relay";
|
||||
import { normalizeRelayConfigs } from "../helpers/relay";
|
||||
import replaceableEventLoaderService from "./replaceable-event-requester";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
export type UserContacts = {
|
||||
pubkey: string;
|
||||
@ -48,21 +48,6 @@ function parseContacts(event: NostrEvent): UserContacts {
|
||||
}
|
||||
|
||||
class UserContactsService {
|
||||
requester: CachedPubkeyEventRequester;
|
||||
|
||||
constructor() {
|
||||
this.requester = new CachedPubkeyEventRequester(3, "user-contacts");
|
||||
this.requester.readCache = this.readCache;
|
||||
this.requester.writeCache = this.writeCache;
|
||||
}
|
||||
|
||||
readCache(pubkey: string) {
|
||||
return db.get("userContacts", pubkey);
|
||||
}
|
||||
writeCache(pubkey: string, event: NostrEvent) {
|
||||
return db.put("userContacts", event);
|
||||
}
|
||||
|
||||
private subjects = new SuperMap<string, Subject<UserContacts>>(() => new Subject<UserContacts>());
|
||||
getSubject(pubkey: string) {
|
||||
return this.subjects.get(pubkey);
|
||||
@ -70,7 +55,13 @@ class UserContactsService {
|
||||
requestContacts(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
const sub = this.subjects.get(pubkey);
|
||||
|
||||
const requestSub = this.requester.requestEvent(pubkey, relays, alwaysRequest);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(
|
||||
relays,
|
||||
Kind.Contacts,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest
|
||||
);
|
||||
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseContacts(event)));
|
||||
|
||||
@ -78,20 +69,12 @@ class UserContactsService {
|
||||
}
|
||||
|
||||
receiveEvent(event: NostrEvent) {
|
||||
this.requester.handleEvent(event);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.requester.update();
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
const userContactsService = new UserContactsService();
|
||||
|
||||
setInterval(() => {
|
||||
userContactsService.update();
|
||||
}, 1000 * 2);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.userContactsService = userContactsService;
|
||||
|
@ -1,99 +0,0 @@
|
||||
import { NostrEvent, isPTag } from "../types/nostr-event";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { PubkeySubjectCache } from "../classes/pubkey-subject-cache";
|
||||
import { NostrMultiSubscription } from "../classes/nostr-multi-subscription";
|
||||
import db from "./db";
|
||||
import userContactsService from "./user-contacts";
|
||||
import { Subject } from "../classes/subject";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
const subscription = new NostrMultiSubscription([], undefined, "user-followers");
|
||||
const subjects = new PubkeySubjectCache<string[]>();
|
||||
const forceRequestedKeys = new Set<string>();
|
||||
|
||||
export type UserFollowers = Set<string>;
|
||||
|
||||
function mergeNext(subject: Subject<string[] | null>, next: string[]) {
|
||||
let arr = subject.value ? Array.from(subject.value) : [];
|
||||
for (const key of next) {
|
||||
if (!arr.includes(key)) arr.push(key);
|
||||
}
|
||||
|
||||
subject.next(arr);
|
||||
}
|
||||
|
||||
function requestFollowers(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
let subject = subjects.getSubject(pubkey);
|
||||
|
||||
if (relays.length) subjects.addRelays(pubkey, relays);
|
||||
|
||||
db.getAllKeysFromIndex("userFollows", "follows", 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 relayUrls = new Set<string>();
|
||||
|
||||
const pending = subjects.getAllPubkeysMissingData(Array.from(forceRequestedKeys));
|
||||
for (const key of pending.pubkeys) pubkeys.add(key);
|
||||
for (const url of pending.relays) relayUrls.add(url);
|
||||
|
||||
if (pubkeys.size === 0) return;
|
||||
|
||||
const query: NostrQuery = { kinds: [3], "#p": Array.from(pubkeys) };
|
||||
|
||||
subscription.setRelays(Array.from(relayUrls));
|
||||
subscription.setQuery(query);
|
||||
if (subscription.state !== NostrMultiSubscription.OPEN) {
|
||||
subscription.open();
|
||||
}
|
||||
subjects.dirty = false;
|
||||
}
|
||||
|
||||
function receiveEvent(event: NostrEvent) {
|
||||
if (event.kind !== Kind.Contacts) return;
|
||||
const follower = event.pubkey;
|
||||
|
||||
const pTags = event.tags.filter(isPTag);
|
||||
if (pTags.length > 0) {
|
||||
for (const [_, pubkey] of pTags) {
|
||||
if (subjects.hasSubject(pubkey)) {
|
||||
const subject = subjects.getSubject(pubkey);
|
||||
mergeNext(subject, [follower]);
|
||||
}
|
||||
|
||||
forceRequestedKeys.delete(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
db.put("userFollows", { pubkey: event.pubkey, follows: pTags.map((p) => p[1]) });
|
||||
}
|
||||
|
||||
subscription.onEvent.subscribe((event) => {
|
||||
// pass the event to the contacts service
|
||||
userContactsService.receiveEvent(event);
|
||||
receiveEvent(event);
|
||||
});
|
||||
|
||||
// flush requests every second
|
||||
setInterval(() => {
|
||||
subjects.prune();
|
||||
flushRequests();
|
||||
}, 1000 * 5);
|
||||
|
||||
const userFollowersService = { requestFollowers, flushRequests, receiveEvent };
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.userFollowersService = userFollowersService;
|
||||
}
|
||||
|
||||
export default userFollowersService;
|
@ -1,17 +1,18 @@
|
||||
import db from "./db";
|
||||
import { CachedPubkeyEventRequester } from "../classes/cached-pubkey-event-requester";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { Kind0ParsedContent, parseKind0Event } from "../helpers/user-metadata";
|
||||
import { SuperMap } from "../classes/super-map";
|
||||
import Subject from "../classes/subject";
|
||||
import replaceableEventLoaderService from "./replaceable-event-requester";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
class UserMetadataService {
|
||||
requester: CachedPubkeyEventRequester;
|
||||
constructor() {
|
||||
this.requester = new CachedPubkeyEventRequester(0, "user-metadata");
|
||||
this.requester.readCache = this.readCache;
|
||||
this.requester.writeCache = this.writeCache;
|
||||
}
|
||||
// requester: CachedPubkeyEventRequester;
|
||||
// constructor() {
|
||||
// this.requester = new CachedPubkeyEventRequester(0, "user-metadata");
|
||||
// this.requester.readCache = this.readCache;
|
||||
// this.requester.writeCache = this.writeCache;
|
||||
// }
|
||||
|
||||
readCache(pubkey: string) {
|
||||
return db.get("userMetadata", pubkey);
|
||||
@ -26,26 +27,24 @@ class UserMetadataService {
|
||||
}
|
||||
requestMetadata(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
const sub = this.parsedSubjects.get(pubkey);
|
||||
const requestSub = this.requester.requestEvent(pubkey, relays, alwaysRequest);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(
|
||||
relays,
|
||||
Kind.Metadata,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest
|
||||
);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseKind0Event(event)));
|
||||
return sub;
|
||||
}
|
||||
|
||||
receiveEvent(event: NostrEvent) {
|
||||
this.requester.handleEvent(event);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.requester.update();
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
const userMetadataService = new UserMetadataService();
|
||||
|
||||
setInterval(() => {
|
||||
userMetadataService.update();
|
||||
}, 1000 * 2);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.userMetadataService = userMetadataService;
|
||||
|
@ -1,12 +1,12 @@
|
||||
import db from "./db";
|
||||
import { isRTag, NostrEvent } from "../types/nostr-event";
|
||||
import { RelayConfig } from "../classes/relay";
|
||||
import { parseRTag } from "../helpers/nostr-event";
|
||||
import { CachedPubkeyEventRequester } from "../classes/cached-pubkey-event-requester";
|
||||
import { SuperMap } from "../classes/super-map";
|
||||
import Subject from "../classes/subject";
|
||||
import { normalizeRelayConfigs } from "../helpers/relay";
|
||||
import userContactsService from "./user-contacts";
|
||||
import replaceableEventLoaderService from "./replaceable-event-requester";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
export type ParsedUserRelays = {
|
||||
pubkey: string;
|
||||
@ -23,20 +23,13 @@ function parseRelaysEvent(event: NostrEvent): ParsedUserRelays {
|
||||
}
|
||||
|
||||
class UserRelaysService {
|
||||
requester: CachedPubkeyEventRequester;
|
||||
constructor() {
|
||||
this.requester = new CachedPubkeyEventRequester(10002, "user-relays");
|
||||
this.requester.readCache = (pubkey) => db.get("userRelays", pubkey);
|
||||
this.requester.writeCache = (pubkey, event) => db.put("userRelays", event);
|
||||
}
|
||||
|
||||
private subjects = new SuperMap<string, Subject<ParsedUserRelays>>(() => new Subject<ParsedUserRelays>());
|
||||
getRelays(pubkey: string) {
|
||||
return this.subjects.get(pubkey);
|
||||
}
|
||||
requestRelays(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
const sub = this.subjects.get(pubkey);
|
||||
const requestSub = this.requester.requestEvent(pubkey, relays, alwaysRequest);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(relays, Kind.RelayList, pubkey);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseRelaysEvent(event)));
|
||||
|
||||
// also fetch the relays from the users contacts
|
||||
@ -51,20 +44,12 @@ class UserRelaysService {
|
||||
}
|
||||
|
||||
receiveEvent(event: NostrEvent) {
|
||||
this.requester.handleEvent(event);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.requester.update();
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
const userRelaysService = new UserRelaysService();
|
||||
|
||||
setInterval(() => {
|
||||
userRelaysService.update();
|
||||
}, 1000 * 2);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.userRelaysService = userRelaysService;
|
||||
|
@ -27,9 +27,6 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { UserLink } from "../../../components/user-link";
|
||||
import dayjs from "dayjs";
|
||||
import relayScoreboardService from "../../../services/relay-scoreboard";
|
||||
import { getEventRelays } from "../../../services/event-relays";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import StreamStatusBadge from "./status-badge";
|
||||
import { CodeIcon } from "../../../components/icons";
|
||||
import RawValue from "../../../components/debug-modals/raw-value";
|
||||
@ -71,7 +68,7 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
<Text>Updated: {dayjs.unix(stream.updated).fromNow()}</Text>
|
||||
{stream.starts && <Text>Started: {dayjs.unix(stream.starts).fromNow()}</Text>}
|
||||
</LinkBox>
|
||||
<Divider />
|
||||
<CardFooter p="2" display="flex" gap="2" alignItems="center">
|
||||
|
@ -10,6 +10,9 @@ import { NostrEvent } from "../../types/nostr-event";
|
||||
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
|
||||
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
|
||||
import useRelaysChanged from "../../hooks/use-relays-changed";
|
||||
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||
import PeopleListProvider, { usePeopleListContext } from "../../components/people-list-selection/people-list-provider";
|
||||
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||
|
||||
function StreamsPage() {
|
||||
// hard code damus and snort relays for finding streams
|
||||
@ -27,7 +30,15 @@ function StreamsPage() {
|
||||
[filterStatus]
|
||||
);
|
||||
|
||||
const timeline = useTimelineLoader(`streams`, readRelays, { kinds: [STREAM_KIND] }, { eventFilter });
|
||||
const { people } = usePeopleListContext();
|
||||
const query =
|
||||
people.length > 0
|
||||
? [
|
||||
{ authors: people, kinds: [STREAM_KIND] },
|
||||
{ "#p": people, kinds: [STREAM_KIND] },
|
||||
]
|
||||
: { kinds: [STREAM_KIND] };
|
||||
const timeline = useTimelineLoader(`streams`, readRelays, query, { eventFilter });
|
||||
|
||||
useRelaysChanged(readRelays, () => timeline.reset());
|
||||
|
||||
@ -45,12 +56,13 @@ function StreamsPage() {
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return Array.from(Object.values(parsedStreams)).sort((a, b) => b.updated - a.updated);
|
||||
return Array.from(Object.values(parsedStreams)).sort((a, b) => (b.starts ?? 0) - (a.starts ?? 0));
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<Flex p="2" gap="2" overflow="hidden" direction="column">
|
||||
<Flex gap="2">
|
||||
<PeopleListSelection maxW="sm" />
|
||||
<Select maxW="sm" value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
|
||||
<option value="live">Live</option>
|
||||
<option value="ended">Ended</option>
|
||||
@ -62,6 +74,7 @@ function StreamsPage() {
|
||||
{streams.map((stream) => (
|
||||
<StreamCard key={stream.event.id} stream={stream} w="sm" />
|
||||
))}
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</Flex>
|
||||
</IntersectionObserverProvider>
|
||||
</Flex>
|
||||
@ -72,7 +85,9 @@ export default function StreamsView() {
|
||||
<RelaySelectionProvider
|
||||
additionalDefaults={["wss://nos.lol", "wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"]}
|
||||
>
|
||||
<StreamsPage />
|
||||
<PeopleListProvider>
|
||||
<StreamsPage />
|
||||
</PeopleListProvider>
|
||||
</RelaySelectionProvider>
|
||||
);
|
||||
}
|
||||
|
@ -27,8 +27,7 @@ import { useAdditionalRelayContext } from "../../providers/additional-relay-cont
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon } from "../../components/icons";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon, LightningIcon } from "../../components/icons";
|
||||
import { normalizeToBech32 } from "../../helpers/nip19";
|
||||
import { Bech32Prefix } from "../../helpers/nip19";
|
||||
import { truncatedId } from "../../helpers/nostr-event";
|
||||
@ -131,6 +130,12 @@ export default function UserAboutTab() {
|
||||
)}
|
||||
|
||||
<Flex gap="2" px="2" direction="column">
|
||||
{metadata?.lud16 && (
|
||||
<Flex gap="2">
|
||||
<LightningIcon />
|
||||
<Text>{metadata.lud16}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{metadata?.nip05 && (
|
||||
<Flex gap="2">
|
||||
<AtIcon />
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Box, Code, Flex, Heading, Input, Link, Spacer, Text } from "@chakra-ui/react";
|
||||
import { Box, Flex, FlexProps, Heading, Input, Link } from "@chakra-ui/react";
|
||||
import { Link as ReactRouterLink } from "react-router-dom";
|
||||
|
||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||
@ -7,14 +7,14 @@ import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
import { UserFollowButton } from "../../../components/user-follow-button";
|
||||
import { useIsMobile } from "../../../hooks/use-is-mobile";
|
||||
|
||||
export const UserCard = ({ pubkey, relay }: { pubkey: string; relay?: string }) => {
|
||||
const isMobile = useIsMobile();
|
||||
export type UserCardProps = { pubkey: string; relay?: string } & Omit<FlexProps, "children">;
|
||||
|
||||
export const UserCard = ({ pubkey, relay, ...props }: UserCardProps) => {
|
||||
const metadata = useUserMetadata(pubkey, relay ? [relay] : []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Flex
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
pl="3"
|
||||
@ -23,20 +23,19 @@ export const UserCard = ({ pubkey, relay }: { pubkey: string; relay?: string })
|
||||
pb="2"
|
||||
overflow="hidden"
|
||||
gap="4"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
{...props}
|
||||
>
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<Flex direction="column" flex={1} overflowY="hidden" overflowX="auto">
|
||||
<Flex direction="column" flex={1} overflow="hidden">
|
||||
<Link as={ReactRouterLink} to={`/u/${normalizeToBech32(pubkey, Bech32Prefix.Pubkey)}`}>
|
||||
<Heading size="sm" whiteSpace="nowrap">
|
||||
<Heading size="sm" whiteSpace="nowrap" isTruncated>
|
||||
{getUserDisplayName(metadata, pubkey)}
|
||||
</Heading>
|
||||
</Link>
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
</Flex>
|
||||
{relay && !isMobile && <Input readOnly value={relay} w="xs" />}
|
||||
<UserFollowButton pubkey={pubkey} size="sm" variant="outline" flexShrink={0} />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
@ -1,52 +1,58 @@
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { Box, Flex, Spinner } from "@chakra-ui/react";
|
||||
import { Flex, SimpleGrid } from "@chakra-ui/react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||
import { Event, Kind } from "nostr-tools";
|
||||
|
||||
import { UserCard } from "./components/user-card";
|
||||
import { useUserFollowers } from "../../hooks/use-user-followers";
|
||||
import { UserCard, UserCardProps } from "./components/user-card";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
|
||||
import { truncatedId } from "../../helpers/nostr-event";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
|
||||
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
function FollowerItem({ index, style, data: followers }: ListChildComponentProps<string[]>) {
|
||||
const pubkey = followers[index];
|
||||
function FollowerItem({ event, ...props }: { event: Event } & Omit<UserCardProps, "pubkey">) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<UserCard key={pubkey + index} pubkey={pubkey} />
|
||||
<div ref={ref}>
|
||||
<UserCard pubkey={event.pubkey} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserFollowersTab() {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
const readRelays = useReadRelayUrls(contextRelays);
|
||||
|
||||
const relays = useReadRelayUrls(useAdditionalRelayContext());
|
||||
const followers = useUserFollowers(pubkey, relays, true);
|
||||
const timeline = useTimelineLoader(`${truncatedId(pubkey)}-followers`, readRelays, {
|
||||
"#p": [pubkey],
|
||||
kinds: [Kind.Contacts],
|
||||
});
|
||||
|
||||
const followerEvents = useSubject(timeline.timeline);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
const followers = useMemo(() => {
|
||||
const dedupe = new Map<string, Event>();
|
||||
for (const event of followerEvents) {
|
||||
dedupe.set(event.pubkey, event);
|
||||
}
|
||||
return Array.from(dedupe.values());
|
||||
}, [followerEvents]);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" p="2" h="90vh">
|
||||
{followers ? (
|
||||
<Box flex={1}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }: { height: number }) => (
|
||||
<FixedSizeList
|
||||
itemCount={followers.length}
|
||||
itemData={followers}
|
||||
itemSize={70}
|
||||
itemKey={(i, d) => d[i]}
|
||||
width="100%"
|
||||
height={height}
|
||||
overscanCount={10}
|
||||
>
|
||||
{FollowerItem}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Box>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</Flex>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<SimpleGrid minChildWidth="4in" spacing="2" py="2">
|
||||
{followers.map((event) => (
|
||||
<FollowerItem key={event.pubkey} event={event} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</IntersectionObserverProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,51 +1,26 @@
|
||||
import { Box, Flex, Spinner } from "@chakra-ui/react";
|
||||
import { SimpleGrid, Spinner } from "@chakra-ui/react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||
|
||||
import { UserCard } from "./components/user-card";
|
||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { UserContacts } from "../../services/user-contacts";
|
||||
|
||||
function ContactItem({ index, style, data: contacts }: ListChildComponentProps<UserContacts>) {
|
||||
const pubkey = contacts.contacts[index];
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<UserCard key={pubkey + index} pubkey={pubkey} relay={contacts.contactRelay[pubkey]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { unique } from "../../helpers/array";
|
||||
|
||||
export default function UserFollowingTab() {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
const contacts = useUserContacts(pubkey, contextRelays, true);
|
||||
|
||||
const people = unique(contacts?.contacts ?? []);
|
||||
|
||||
if (!contacts) return <Spinner />;
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" p="2" h="90vh">
|
||||
{contacts ? (
|
||||
<Box flex={1}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }: { height: number }) => (
|
||||
<FixedSizeList
|
||||
itemCount={contacts.contacts.length}
|
||||
itemData={contacts}
|
||||
itemSize={70}
|
||||
itemKey={(i, d) => d.contacts[i]}
|
||||
width="100%"
|
||||
height={height}
|
||||
overscanCount={10}
|
||||
>
|
||||
{ContactItem}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Box>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</Flex>
|
||||
<SimpleGrid minChildWidth="4in" spacing="2" py="2">
|
||||
{people.map((pubkey) => (
|
||||
<UserCard key={pubkey} pubkey={pubkey} relay={contacts?.contactRelay[pubkey]} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ export default function UserLikesTab() {
|
||||
|
||||
const timeline = useTimelineLoader(`${truncatedId(pubkey)}-likes`, readRelays, { authors: [pubkey], kinds: [7] });
|
||||
|
||||
const lines = useSubject(timeline.timeline);
|
||||
const likes = useSubject(timeline.timeline);
|
||||
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
@ -66,7 +66,7 @@ export default function UserLikesTab() {
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<TrustProvider trust>
|
||||
<Flex direction="column" gap="2" p="2" pb="8">
|
||||
{lines.map((event) => (
|
||||
{likes.map((event) => (
|
||||
<Like event={event} />
|
||||
))}
|
||||
|
||||
|
@ -16,7 +16,7 @@ export default defineConfig({
|
||||
name: "noStrudel",
|
||||
short_name: "noStrudel",
|
||||
description: "A simple PWA nostr client",
|
||||
orientation: "portrait-primary",
|
||||
orientation: "any",
|
||||
theme_color: "#8DB600",
|
||||
categories: ["nostr"],
|
||||
icons: [
|
||||
|
25
yarn.lock
25
yarn.lock
@ -2621,13 +2621,6 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-window@^1.8.5":
|
||||
version "1.8.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
|
||||
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^16.9.35", "@types/react@^18.2.14":
|
||||
version "18.2.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.15.tgz#14792b35df676c20ec3cf595b262f8c615a73066"
|
||||
@ -4852,11 +4845,6 @@ mdn-data@2.0.14:
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||
|
||||
meow@^6.0.0:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/meow/-/meow-6.1.1.tgz#1ad64c4b76b2a24dfb2f635fddcadf320d251467"
|
||||
@ -5474,24 +5462,11 @@ react-use@^17.4.0:
|
||||
ts-easing "^0.2.0"
|
||||
tslib "^2.1.0"
|
||||
|
||||
react-virtualized-auto-sizer@^1.0.20:
|
||||
version "1.0.20"
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.20.tgz#d9a907253a7c221c52fa57dc775a6ef40c182645"
|
||||
integrity sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA==
|
||||
|
||||
react-webcam@^5.0.1:
|
||||
version "5.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-webcam/-/react-webcam-5.2.4.tgz#714b4460ea43ac7ed081824299cd2a580f764478"
|
||||
integrity sha512-Qqj14t68Ke1eoEYjFde+N48HtuIJg0ePIQRpFww9eZt5oBcDpe/l60h+m3VRFJAR5/E3dOhSU5R8EJEcdCq/Eg==
|
||||
|
||||
react-window@^1.8.9:
|
||||
version "1.8.9"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8"
|
||||
integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
|
Loading…
x
Reference in New Issue
Block a user