Finish basic list views

This commit is contained in:
hzrd149
2023-08-24 09:06:13 -05:00
parent d6f86d4511
commit 850e1914fb
21 changed files with 295 additions and 264 deletions

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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: [] });

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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":

View 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);
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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)));

View File

@@ -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;

View File

@@ -76,6 +76,7 @@ class UserContactsService {
}
}
/** @deprecated */
const userContactsService = new UserContactsService();
if (import.meta.env.DEV) {

View File

@@ -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;

View File

@@ -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,

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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());

View File

@@ -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}

View File

@@ -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
)}
</>
);
};
}