mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-20 04:20:39 +02:00
Finish basic list views
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { isReplaceable } from "../helpers/nostr/events";
|
||||
import { addToLog } from "../services/publish-log";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import createDefer from "./deferred";
|
||||
import { IncomingCommandResult, Relay } from "./relay";
|
||||
@@ -36,6 +38,11 @@ export default class NostrPublishAction {
|
||||
setTimeout(this.handleTimeout.bind(this), timeout);
|
||||
|
||||
addToLog(this);
|
||||
|
||||
// if this is replaceable, mirror it over to the replaceable event service
|
||||
if (isReplaceable(event.kind)) {
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResult(result: IncomingCommandResult) {
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import dayjs from "dayjs";
|
||||
import { utils } from "nostr-tools";
|
||||
import { Debugger } from "debug";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
|
||||
@@ -8,6 +7,8 @@ import { NostrMultiSubscription } from "./nostr-multi-subscription";
|
||||
import Subject, { PersistentSubject } from "./subject";
|
||||
import { logger } from "../helpers/debug";
|
||||
import EventStore from "./event-store";
|
||||
import { isReplaceable } from "../helpers/nostr/events";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
|
||||
function addToQuery(filter: NostrRequestFilter, query: NostrQuery) {
|
||||
if (Array.isArray(filter)) {
|
||||
@@ -56,9 +57,6 @@ class RelayTimelineLoader {
|
||||
|
||||
let gotEvents = 0;
|
||||
request.onEvent.subscribe((e) => {
|
||||
// if(oldestEvent && e.created_at<oldestEvent.created_at){
|
||||
// this.log('Got event older than oldest')
|
||||
// }
|
||||
if (this.handleEvent(e)) {
|
||||
gotEvents++;
|
||||
}
|
||||
@@ -120,6 +118,10 @@ export class TimelineLoader {
|
||||
} else this.timeline.next(this.events.getSortedEvents());
|
||||
}
|
||||
private handleEvent(event: NostrEvent) {
|
||||
// if this is a replaceable event, mirror it over to the replaceable event service
|
||||
if (isReplaceable(event.kind)) {
|
||||
replaceableEventLoaderService.handleEvent(event);
|
||||
}
|
||||
this.events.addEvent(event);
|
||||
}
|
||||
|
||||
|
@@ -1,12 +1,9 @@
|
||||
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";
|
||||
import useUserContactList from "../../hooks/use-user-contact-list";
|
||||
import { getPubkeysFromList } from "../../helpers/nostr/lists";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
|
||||
export type ListIdentifier = "following" | "global" | string;
|
||||
|
||||
@@ -21,33 +18,22 @@ export function useParsedNaddr(naddr?: string) {
|
||||
} 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 account = useCurrentAccount();
|
||||
const contacts = useUserContactList(account?.pubkey);
|
||||
|
||||
const listEvent = useList(list);
|
||||
const listEvent = useReplaceableEvent(list.includes(":") ? list : undefined);
|
||||
|
||||
if (list === "following") return contacts.map((t) => t[1]);
|
||||
if (list === "following") return contacts ? getPubkeysFromList(contacts) : [];
|
||||
if (listEvent) {
|
||||
return listEvent.tags.filter(isPTag).map((t) => t[1]);
|
||||
return getPubkeysFromList(listEvent);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export type PeopleListContextType = {
|
||||
list: string;
|
||||
people: string[];
|
||||
people: { pubkey: string; relay?: string }[];
|
||||
setList: (list: string) => void;
|
||||
};
|
||||
const PeopleListContext = createContext<PeopleListContextType>({ list: "following", setList: () => {}, people: [] });
|
||||
|
@@ -1,5 +1,23 @@
|
||||
import { Select, SelectProps, useDisclosure } from "@chakra-ui/react";
|
||||
import { Select, SelectProps } from "@chakra-ui/react";
|
||||
import { usePeopleListContext } from "./people-list-provider";
|
||||
import useUserLists from "../../hooks/use-user-lists";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { getListName } from "../../helpers/nostr/lists";
|
||||
import { getEventCoordinate } from "../../helpers/nostr/events";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
function UserListOptions() {
|
||||
const account = useCurrentAccount()!;
|
||||
const lists = useUserLists(account?.pubkey);
|
||||
|
||||
return (
|
||||
<>
|
||||
{lists.map((list) => (
|
||||
<option value={getEventCoordinate(list)}>{getListName(list)}</option>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PeopleListSelection({
|
||||
hideGlobalOption = false,
|
||||
@@ -7,8 +25,8 @@ export default function PeopleListSelection({
|
||||
}: {
|
||||
hideGlobalOption?: boolean;
|
||||
} & Omit<SelectProps, "value" | "onChange" | "children">) {
|
||||
const { people, list, setList } = usePeopleListContext();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const account = useCurrentAccount()!;
|
||||
const { list, setList } = usePeopleListContext();
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -18,8 +36,9 @@ export default function PeopleListSelection({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<option value="following">Following</option>
|
||||
{account && <option value={`${Kind.Contacts}:${account.pubkey}`}>Following</option>}
|
||||
{!hideGlobalOption && <option value="global">Global</option>}
|
||||
{account && <UserListOptions />}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
@@ -7,10 +7,10 @@ import {
|
||||
MenuList,
|
||||
MenuItem,
|
||||
MenuItemOption,
|
||||
MenuGroup,
|
||||
MenuOptionGroup,
|
||||
MenuDivider,
|
||||
useToast,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
@@ -30,12 +30,15 @@ import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
import clientRelaysService from "../services/client-relays";
|
||||
import useUserContactList from "../hooks/use-user-contact-list";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import useAsyncErrorHandler from "../hooks/use-async-error-handler";
|
||||
import NewListModal from "../views/lists/components/new-list-modal";
|
||||
|
||||
function UsersLists({ pubkey }: { pubkey: string }) {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount()!;
|
||||
const { requestSignature } = useSigningContext();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const newListModal = useDisclosure();
|
||||
|
||||
const lists = useUserLists(pubkey);
|
||||
|
||||
@@ -93,6 +96,12 @@ function UsersLists({ pubkey }: { pubkey: string }) {
|
||||
))}
|
||||
</MenuOptionGroup>
|
||||
)}
|
||||
<MenuDivider />
|
||||
<MenuItem icon={<PlusCircleIcon />} onClick={newListModal.onOpen}>
|
||||
New list
|
||||
</MenuItem>
|
||||
|
||||
{newListModal.isOpen && <NewListModal onClose={newListModal.onClose} isOpen onCreated={newListModal.onClose} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -103,34 +112,25 @@ export type UserFollowButtonProps = { pubkey: string; showLists?: boolean } & Om
|
||||
>;
|
||||
|
||||
export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButtonProps) => {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount()!;
|
||||
const { requestSignature } = useSigningContext();
|
||||
const contacts = useUserContactList(account?.pubkey);
|
||||
const contacts = useUserContactList(account?.pubkey, [], true);
|
||||
|
||||
const isFollowing = isPubkeyInList(contacts, pubkey);
|
||||
const isDisabled = account?.readonly ?? true;
|
||||
|
||||
const handleFollow = async () => {
|
||||
try {
|
||||
const draft = draftAddPerson(contacts || createEmptyContactList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Follow", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
};
|
||||
const handleUnfollow = async () => {
|
||||
try {
|
||||
const draft = draftRemovePerson(contacts || createEmptyContactList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Unfollow", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
};
|
||||
const handleFollow = useAsyncErrorHandler(async () => {
|
||||
const draft = draftAddPerson(contacts || createEmptyContactList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Follow", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
});
|
||||
const handleUnfollow = useAsyncErrorHandler(async () => {
|
||||
const draft = draftRemovePerson(contacts || createEmptyContactList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Unfollow", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
});
|
||||
|
||||
if (showLists) {
|
||||
return (
|
||||
@@ -152,10 +152,6 @@ export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButt
|
||||
<>
|
||||
<MenuDivider />
|
||||
<UsersLists pubkey={pubkey} />
|
||||
<MenuDivider />
|
||||
<MenuItem icon={<PlusCircleIcon />} isDisabled={true}>
|
||||
New list
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuList>
|
||||
|
@@ -15,14 +15,14 @@ export function truncatedId(str: string, keep = 6) {
|
||||
return str.substring(0, keep) + "..." + str.substring(str.length - keep);
|
||||
}
|
||||
|
||||
// based on replaceable kinds from https://github.com/nostr-protocol/nips/blob/master/01.md#kinds
|
||||
export function isReplaceable(kind: number) {
|
||||
return (kind >= 30000 && kind < 40000) || kind === 0 || kind === 3 || (kind >= 10000 && kind < 20000);
|
||||
}
|
||||
|
||||
// used to get a unique Id for each event, should take into account replaceable events
|
||||
export function getEventUID(event: NostrEvent) {
|
||||
if (
|
||||
(event.kind >= 30000 && event.kind < 40000) ||
|
||||
event.kind === 0 ||
|
||||
event.kind === 3 ||
|
||||
(event.kind >= 10000 && event.kind < 20000)
|
||||
) {
|
||||
if (isReplaceable(event.kind)) {
|
||||
return getEventCoordinate(event);
|
||||
}
|
||||
return event.id;
|
||||
@@ -197,15 +197,6 @@ export function buildQuoteRepost(event: NostrEvent): DraftNostrEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDeleteEvent(eventIds: string[], reason = ""): DraftNostrEvent {
|
||||
return {
|
||||
kind: Kind.EventDeletion,
|
||||
tags: eventIds.map((id) => ["e", id]),
|
||||
content: reason,
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRTag(tag: RTag): RelayConfig {
|
||||
switch (tag[2]) {
|
||||
case "write":
|
||||
|
14
src/hooks/use-async-error-handler.ts
Normal file
14
src/hooks/use-async-error-handler.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { DependencyList, useCallback } from "react";
|
||||
|
||||
export default function useAsyncErrorHandler<T = any>(fn: () => Promise<T>, deps: DependencyList = []): () => Promise<T | undefined> {
|
||||
const toast = useToast();
|
||||
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
}, deps);
|
||||
}
|
@@ -1,15 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import useSubject from "./use-subject";
|
||||
import userMuteListService from "../services/user-mute-list";
|
||||
import useReplaceableEvent from "./use-replaceable-event";
|
||||
import { MUTE_LIST_KIND } from "../helpers/nostr/lists";
|
||||
|
||||
export default function useUserMuteList(pubkey?: string, additionalRelays?: string[], alwaysRequest = false) {
|
||||
const relays = useReadRelayUrls(additionalRelays);
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (!pubkey) return;
|
||||
return userMuteListService.requestMuteList(relays, pubkey, alwaysRequest);
|
||||
}, [pubkey]);
|
||||
|
||||
return useSubject(sub);
|
||||
export default function useUserMuteList(pubkey?: string, additionalRelays: string[] = [], alwaysRequest = true) {
|
||||
return useReplaceableEvent(pubkey && { kind: MUTE_LIST_KIND, pubkey }, additionalRelays, alwaysRequest);
|
||||
}
|
||||
|
@@ -22,6 +22,7 @@ import {
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { Event, Kind, nip19 } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import signingService from "../services/signing";
|
||||
@@ -31,8 +32,9 @@ import useEventRelays from "../hooks/use-event-relays";
|
||||
import { useWriteRelayUrls } from "../hooks/use-client-relays";
|
||||
import { RelayFavicon } from "../components/relay-favicon";
|
||||
import { ExternalLinkIcon } from "../components/icons";
|
||||
import { buildDeleteEvent } from "../helpers/nostr/events";
|
||||
import { getEventCoordinate, isReplaceable } from "../helpers/nostr/events";
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
import { Tag } from "../types/nostr-event";
|
||||
|
||||
type DeleteEventContextType = {
|
||||
isLoading: boolean;
|
||||
@@ -79,9 +81,18 @@ export default function DeleteEventProvider({ children }: PropsWithChildren) {
|
||||
if (!event) throw new Error("no event");
|
||||
if (!account) throw new Error("not logged in");
|
||||
setLoading(true);
|
||||
const deleteEvent = buildDeleteEvent([event.id], reason);
|
||||
const signed = await signingService.requestSignature(deleteEvent, account);
|
||||
const tags: Tag[] = [["e", event.id]];
|
||||
if (isReplaceable(event.kind)) {
|
||||
tags.push(["a", getEventCoordinate(event)]);
|
||||
}
|
||||
|
||||
const draft = {
|
||||
kind: Kind.EventDeletion,
|
||||
tags,
|
||||
content: reason,
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
const signed = await signingService.requestSignature(draft, account);
|
||||
const pub = new NostrPublishAction("Delete", writeRelays, signed);
|
||||
await pub.onComplete;
|
||||
defer?.resolve();
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import React, { PropsWithChildren, useContext } from "react";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import clientFollowingService from "../services/client-following";
|
||||
import useSubject from "../hooks/use-subject";
|
||||
import useUserContactList from "../hooks/use-user-contact-list";
|
||||
import { getPubkeysFromList } from "../helpers/nostr/lists";
|
||||
|
||||
const TrustContext = React.createContext<boolean>(false);
|
||||
|
||||
@@ -18,7 +18,8 @@ export function TrustProvider({
|
||||
const parentTrust = useContext(TrustContext);
|
||||
|
||||
const account = useCurrentAccount();
|
||||
const following = useSubject(clientFollowingService.following).map((p) => p[1]);
|
||||
const contacts = useUserContactList(account?.pubkey)
|
||||
const following = contacts ? getPubkeysFromList(contacts).map(p => p.pubkey) : []
|
||||
|
||||
const isEventTrusted = trust || (!!event && (event.pubkey === account?.pubkey || following.includes(event.pubkey)));
|
||||
|
||||
|
@@ -1,131 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { PersistentSubject, Subject } from "../classes/subject";
|
||||
import { DraftNostrEvent, PTag } from "../types/nostr-event";
|
||||
import clientRelaysService from "./client-relays";
|
||||
import accountService from "./account";
|
||||
import userContactsService, { UserContacts } from "./user-contacts";
|
||||
import signingService from "./signing";
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
|
||||
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
const following = new PersistentSubject<PTag[]>([]);
|
||||
const pendingDraft = new PersistentSubject<DraftNostrEvent | null>(null);
|
||||
const savingDraft = new PersistentSubject(false);
|
||||
|
||||
function handleNewContacts(contacts: UserContacts | undefined) {
|
||||
if (!contacts) return;
|
||||
|
||||
following.next(
|
||||
contacts.contacts.map((key) => {
|
||||
const relay = contacts.contactRelay[key];
|
||||
if (relay) return ["p", key, relay];
|
||||
else return ["p", key];
|
||||
}),
|
||||
);
|
||||
|
||||
// reset the pending list since we just got a new contacts list
|
||||
pendingDraft.next(null);
|
||||
}
|
||||
|
||||
let sub: Subject<UserContacts> | undefined;
|
||||
function updateSub() {
|
||||
const pubkey = accountService.current.value?.pubkey;
|
||||
if (sub) {
|
||||
sub.unsubscribe(handleNewContacts);
|
||||
sub = undefined;
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
sub = userContactsService.requestContacts(pubkey, clientRelaysService.getReadUrls(), true);
|
||||
|
||||
sub.subscribe(handleNewContacts);
|
||||
}
|
||||
}
|
||||
|
||||
accountService.current.subscribe(() => {
|
||||
// clear the following list until a new one can be fetched
|
||||
following.next([]);
|
||||
|
||||
updateSub();
|
||||
});
|
||||
|
||||
clientRelaysService.readRelays.subscribe(() => {
|
||||
updateSub();
|
||||
});
|
||||
|
||||
function isFollowing(pubkey: string) {
|
||||
return !!following.value?.some((t) => t[1] === pubkey);
|
||||
}
|
||||
|
||||
function getDraftEvent(): DraftNostrEvent {
|
||||
return {
|
||||
kind: 3,
|
||||
tags: following.value,
|
||||
// according to NIP-02 kind 3 events (contact list) can have any content and it should be ignored
|
||||
// https://github.com/nostr-protocol/nips/blob/master/02.md
|
||||
// some other clients are using the content to store relays.
|
||||
content: "",
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
}
|
||||
|
||||
async function savePending() {
|
||||
const draft = pendingDraft.value;
|
||||
if (!draft) return;
|
||||
|
||||
savingDraft.next(true);
|
||||
const current = accountService.current.value;
|
||||
if (!current) throw new Error("no account");
|
||||
const signed = await signingService.requestSignature(draft, current);
|
||||
|
||||
const pub = new NostrPublishAction("Update Following", clientRelaysService.getWriteUrls(), signed);
|
||||
await pub.onComplete;
|
||||
|
||||
savingDraft.next(false);
|
||||
|
||||
// pass new event to contact list service
|
||||
userContactsService.receiveEvent(signed);
|
||||
}
|
||||
|
||||
function addContact(pubkey: string, relay?: string) {
|
||||
const newTag: PTag = relay ? ["p", pubkey, relay] : ["p", pubkey];
|
||||
const pTags = following.value;
|
||||
if (isFollowing(pubkey)) {
|
||||
following.next(
|
||||
pTags.map((t) => {
|
||||
if (t[1] === pubkey) {
|
||||
return newTag;
|
||||
}
|
||||
return t;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
following.next([...pTags, newTag]);
|
||||
}
|
||||
|
||||
pendingDraft.next(getDraftEvent());
|
||||
}
|
||||
function removeContact(pubkey: string) {
|
||||
if (isFollowing(pubkey)) {
|
||||
const pTags = following.value;
|
||||
following.next(pTags.filter((t) => t[1] !== pubkey));
|
||||
pendingDraft.next(getDraftEvent());
|
||||
}
|
||||
}
|
||||
|
||||
const clientFollowingService = {
|
||||
following,
|
||||
isFollowing,
|
||||
savingDraft,
|
||||
savePending,
|
||||
addContact,
|
||||
removeContact,
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.clientFollowingService = clientFollowingService;
|
||||
}
|
||||
|
||||
export default clientFollowingService;
|
@@ -76,6 +76,7 @@ class UserContactsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
const userContactsService = new UserContactsService();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
|
@@ -1,14 +0,0 @@
|
||||
import replaceableEventLoaderService from "./replaceable-event-requester";
|
||||
|
||||
class UserMuteListService {
|
||||
getMuteList(pubkey: string) {
|
||||
return replaceableEventLoaderService.getEvent(10000, pubkey);
|
||||
}
|
||||
requestMuteList(relays: string[], pubkey: string, alwaysRequest = false) {
|
||||
return replaceableEventLoaderService.requestEvent(relays, 10000, pubkey, undefined, alwaysRequest);
|
||||
}
|
||||
}
|
||||
|
||||
const userMuteListService = new UserMuteListService();
|
||||
|
||||
export default userMuteListService;
|
@@ -5,18 +5,18 @@ import { Kind } from "nostr-tools";
|
||||
|
||||
import { isReply, truncatedId } from "../../helpers/nostr/events";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
|
||||
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
|
||||
import useUserContactList from "../../hooks/use-user-contact-list";
|
||||
import { getPubkeysFromList } from "../../helpers/nostr/lists";
|
||||
|
||||
function FollowingTabBody() {
|
||||
const account = useCurrentAccount()!;
|
||||
const readRelays = useReadRelayUrls();
|
||||
const contacts = useUserContacts(account.pubkey, readRelays);
|
||||
const contacts = useUserContactList(account.pubkey);
|
||||
const [search, setSearch] = useSearchParams();
|
||||
const showReplies = search.has("replies");
|
||||
const onToggle = () => {
|
||||
@@ -32,7 +32,8 @@ function FollowingTabBody() {
|
||||
[showReplies, timelinePageEventFilter],
|
||||
);
|
||||
|
||||
const following = contacts?.contacts || [];
|
||||
const following = contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : [];
|
||||
const readRelays = useReadRelayUrls();
|
||||
const timeline = useTimelineLoader(
|
||||
`${truncatedId(account.pubkey)}-following`,
|
||||
readRelays,
|
||||
|
83
src/views/lists/components/new-list-modal.tsx
Normal file
83
src/views/lists/components/new-list-modal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
ModalProps,
|
||||
Select,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../../helpers/nostr/lists";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import clientRelaysService from "../../../services/client-relays";
|
||||
|
||||
export type NewListModalProps = { onCreated?: (list: NostrEvent) => void } & Omit<ModalProps, "children">;
|
||||
|
||||
export default function NewListModal({ onClose, onCreated, ...props }: NewListModalProps) {
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const { handleSubmit, register, formState } = useForm({
|
||||
defaultValues: {
|
||||
kind: PEOPLE_LIST_KIND,
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
const draft: DraftNostrEvent = {
|
||||
content: "",
|
||||
created_at: dayjs().unix(),
|
||||
tags: [["d", values.name]],
|
||||
kind: values.kind,
|
||||
};
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Create list", clientRelaysService.getWriteUrls(), signed);
|
||||
|
||||
if (onCreated) onCreated(signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">New List</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as="form" p="4" display="flex" gap="2" flexDirection="column" onSubmit={submit}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>List kind</FormLabel>
|
||||
<Select {...register("kind", { valueAsNumber: true, required: true })}>
|
||||
<option value={PEOPLE_LIST_KIND}>People List</option>
|
||||
<option value={NOTE_LIST_KIND}>Note List</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input type="text" {...register("name", { required: true })} autoComplete="off" />
|
||||
</FormControl>
|
||||
<ButtonGroup ml="auto">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
53
src/views/lists/components/user-card.tsx
Normal file
53
src/views/lists/components/user-card.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Button, Card, CardBody, CardProps, Flex, Heading, Link } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||
import { getUserDisplayName } from "../../../helpers/user-metadata";
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import useAsyncErrorHandler from "../../../hooks/use-async-error-handler";
|
||||
import { draftRemovePerson } from "../../../helpers/nostr/lists";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import clientRelaysService from "../../../services/client-relays";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import { UserFollowButton } from "../../../components/user-follow-button";
|
||||
|
||||
export type UserCardProps = { pubkey: string; relay?: string; list: NostrEvent } & Omit<CardProps, "children">;
|
||||
|
||||
export default function UserCard({ pubkey, relay, list, ...props }: UserCardProps) {
|
||||
const account = useCurrentAccount();
|
||||
const metadata = useUserMetadata(pubkey, relay ? [relay] : []);
|
||||
const { requestSignature } = useSigningContext();
|
||||
|
||||
const handleRemoveFromList = useAsyncErrorHandler(async () => {
|
||||
const draft = draftRemovePerson(list, pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Remove from list", clientRelaysService.getWriteUrls(), signed);
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody p="2" display="flex" alignItems="center" overflow="hidden" gap="2">
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<Flex direction="column" flex={1} overflow="hidden">
|
||||
<Link as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}`}>
|
||||
<Heading size="sm" whiteSpace="nowrap" isTruncated>
|
||||
{getUserDisplayName(metadata, pubkey)}
|
||||
</Heading>
|
||||
</Link>
|
||||
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||
</Flex>
|
||||
{account?.pubkey === list.pubkey ? (
|
||||
<Button variant="outline" colorScheme="orange" onClick={handleRemoveFromList}>
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<UserFollowButton pubkey={pubkey} variant="outline" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -1,14 +1,19 @@
|
||||
import { Button, Flex, Image, Link, Spacer } from "@chakra-ui/react";
|
||||
import { Button, Flex, Image, Link, Spacer, useDisclosure } from "@chakra-ui/react";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { ExternalLinkIcon, PlusCircleIcon } from "../../components/icons";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import ListCard from "./components/list-card";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import useUserLists from "../../hooks/use-user-lists";
|
||||
import NewListModal from "./components/new-list-modal";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getSharableEventNaddr } from "../../helpers/nip19";
|
||||
|
||||
function ListsPage() {
|
||||
const account = useCurrentAccount()!;
|
||||
const events = useUserLists(account.pubkey);
|
||||
const newList = useDisclosure();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Flex direction="column" p="2" gap="2">
|
||||
@@ -23,7 +28,9 @@ function ListsPage() {
|
||||
>
|
||||
Listr
|
||||
</Button>
|
||||
<Button leftIcon={<PlusCircleIcon />}>New List</Button>
|
||||
<Button leftIcon={<PlusCircleIcon />} onClick={newList.onOpen}>
|
||||
New List
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<ListCard cord={`3:${account.pubkey}`} />
|
||||
@@ -31,6 +38,14 @@ function ListsPage() {
|
||||
{events.map((event) => (
|
||||
<ListCard key={getEventUID(event)} event={event} />
|
||||
))}
|
||||
|
||||
{newList.isOpen && (
|
||||
<NewListModal
|
||||
isOpen
|
||||
onClose={newList.onClose}
|
||||
onCreated={(list) => navigate(`/lists/${getSharableEventNaddr(list)}`)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@@ -2,15 +2,16 @@ import { Link as RouterList, useNavigate, useParams } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { UserLink } from "../../components/user-link";
|
||||
import { Button, Flex, Heading } from "@chakra-ui/react";
|
||||
import { UserCard } from "../user/components/user-card";
|
||||
import { ArrowLeftSIcon } from "../../components/icons";
|
||||
import { Button, Flex, Heading, IconButton, useDisclosure } from "@chakra-ui/react";
|
||||
import { ArrowLeftSIcon, CodeIcon } from "../../components/icons";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { useDeleteEventContext } from "../../providers/delete-event-provider";
|
||||
import { parseCoordinate } from "../../helpers/nostr/events";
|
||||
import { getListName, getPubkeysFromList } from "../../helpers/nostr/lists";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import { EventRelays } from "../../components/note/note-relays";
|
||||
import UserCard from "./components/user-card";
|
||||
import NoteDebugModal from "../../components/debug-modals/note-debug-modal";
|
||||
|
||||
function useListCoordinate() {
|
||||
const { addr } = useParams() as { addr: string };
|
||||
@@ -28,6 +29,7 @@ function useListCoordinate() {
|
||||
|
||||
export default function ListView() {
|
||||
const navigate = useNavigate();
|
||||
const debug = useDisclosure();
|
||||
const coordinate = useListCoordinate();
|
||||
const { deleteEvent } = useDeleteEventContext();
|
||||
const account = useCurrentAccount();
|
||||
@@ -62,10 +64,13 @@ export default function ListView() {
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<IconButton icon={<CodeIcon />} aria-label="Show raw" onClick={debug.onOpen} />
|
||||
</Flex>
|
||||
{people.map(({ pubkey, relay }) => (
|
||||
<UserCard pubkey={pubkey} relay={relay} />
|
||||
<UserCard pubkey={pubkey} relay={relay} list={event} />
|
||||
))}
|
||||
|
||||
{debug.isOpen && <NoteDebugModal event={event} isOpen onClose={debug.onClose} size="4xl" />}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Flex, Select, SimpleGrid } from "@chakra-ui/react";
|
||||
import { Code, Flex, Select, SimpleGrid } from "@chakra-ui/react";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
@@ -30,14 +30,16 @@ function StreamsPage() {
|
||||
[filterStatus],
|
||||
);
|
||||
|
||||
const { people } = usePeopleListContext();
|
||||
const { people, list } = usePeopleListContext();
|
||||
const query =
|
||||
people.length > 0
|
||||
? [
|
||||
{ authors: people, kinds: [STREAM_KIND] },
|
||||
{ "#p": people, kinds: [STREAM_KIND] },
|
||||
{ authors: people.map((p) => p.pubkey), kinds: [STREAM_KIND] },
|
||||
{ "#p": people.map((p) => p.pubkey), kinds: [STREAM_KIND] },
|
||||
]
|
||||
: { kinds: [STREAM_KIND] };
|
||||
|
||||
// TODO: put the list id into the timeline key so it refreshes (probably have to hash the list id since its >64)
|
||||
const timeline = useTimelineLoader(`streams`, relays, query, { eventFilter });
|
||||
|
||||
useRelaysChanged(relays, () => timeline.reset());
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { useOutletContext, Link as RouterLink } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
@@ -23,12 +22,15 @@ import {
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useAsync } from "react-use";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { readablizeSats } from "../../helpers/bolt11";
|
||||
import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
import { getLudEndpoint } from "../../helpers/lnurl";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import { truncatedId } from "../../helpers/nostr/events";
|
||||
import userTrustedStatsService from "../../services/user-trusted-stats";
|
||||
import { parseAddress } from "../../services/dns-identity";
|
||||
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import { embedNostrLinks, renderGenericUrl } from "../../components/embed-types";
|
||||
@@ -36,16 +38,12 @@ import { ArrowDownSIcon, ArrowUpSIcon, AtIcon, ExternalLinkIcon, KeyIcon, Lightn
|
||||
import { CopyIconButton } from "../../components/copy-icon-button";
|
||||
import { QrIconButton } from "./components/share-qr-button";
|
||||
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||
import { useUserContacts } from "../../hooks/use-user-contacts";
|
||||
import userTrustedStatsService from "../../services/user-trusted-stats";
|
||||
import { UserAvatar } from "../../components/user-avatar";
|
||||
import { ChatIcon } from "@chakra-ui/icons";
|
||||
import { UserFollowButton } from "../../components/user-follow-button";
|
||||
import { UserTipButton } from "../../components/user-tip-button";
|
||||
import UserZapButton from "./components/user-zap-button";
|
||||
import { UserProfileMenu } from "./components/user-profile-menu";
|
||||
import { useSharableProfileId } from "../../hooks/use-shareable-profile-id";
|
||||
import { parseAddress } from "../../services/dns-identity";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import useUserContactList from "../../hooks/use-user-contact-list";
|
||||
import { getPubkeysFromList } from "../../helpers/nostr/lists";
|
||||
|
||||
@@ -113,7 +111,7 @@ export default function UserAboutTab() {
|
||||
</Box>
|
||||
|
||||
<Flex gap="2" ml="auto">
|
||||
<UserTipButton pubkey={pubkey} size="sm" variant="link" />
|
||||
<UserZapButton pubkey={pubkey} size="sm" variant="link" />
|
||||
|
||||
<IconButton
|
||||
as={RouterLink}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
|
||||
import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
import { LightningIcon } from "./icons";
|
||||
import ZapModal from "./zap-modal";
|
||||
import { useInvoiceModalContext } from "../providers/invoice-modal";
|
||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||
import { LightningIcon } from "../../../components/icons";
|
||||
import ZapModal from "../../../components/zap-modal";
|
||||
import { useInvoiceModalContext } from "../../../providers/invoice-modal";
|
||||
|
||||
export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) => {
|
||||
export default function UserZapButton({ pubkey, ...props }: { pubkey: string } & Omit<IconButtonProps, "aria-label">) {
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { requestPay } = useInvoiceModalContext();
|
||||
@@ -38,4 +38,4 @@ export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit<Ic
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user