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
f12e39eddf
5
.changeset/eight-eggs-turn.md
Normal file
5
.changeset/eight-eggs-turn.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add community browse view
|
5
.changeset/gold-shoes-type.md
Normal file
5
.changeset/gold-shoes-type.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Small fix for url RegExp
|
5
.changeset/itchy-avocados-laugh.md
Normal file
5
.changeset/itchy-avocados-laugh.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Hide muted users in stream views
|
5
.changeset/khaki-needles-brake.md
Normal file
5
.changeset/khaki-needles-brake.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add option to add user to k 10000 mute list
|
5
.changeset/long-boxes-sniff.md
Normal file
5
.changeset/long-boxes-sniff.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add side drawer for viewing threads
|
5
.changeset/nervous-pigs-judge.md
Normal file
5
.changeset/nervous-pigs-judge.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Cleanup sidenav
|
5
.changeset/ninety-shrimps-run.md
Normal file
5
.changeset/ninety-shrimps-run.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add Max Page width option to display settings
|
5
.changeset/olive-peaches-poke.md
Normal file
5
.changeset/olive-peaches-poke.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Filter out muted users in home feed
|
5
.changeset/polite-points-retire.md
Normal file
5
.changeset/polite-points-retire.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Show multiple pubkeys on badge award event
|
5
.changeset/poor-pumas-rush.md
Normal file
5
.changeset/poor-pumas-rush.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Clean up embedded note component
|
5
.changeset/rich-pigs-knock.md
Normal file
5
.changeset/rich-pigs-knock.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Hide muted users in threads
|
5
.changeset/strong-mayflies-drop.md
Normal file
5
.changeset/strong-mayflies-drop.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add tabs to notification view
|
5
.changeset/unlucky-cooks-help.md
Normal file
5
.changeset/unlucky-cooks-help.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add Muted words option in display settings
|
5
.changeset/wet-plants-joke.md
Normal file
5
.changeset/wet-plants-joke.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix follow and mute button not updating when switching accounts
|
@ -13,8 +13,8 @@
|
||||
"analyze": "npx vite-bundle-visualizer -o ./stats.html"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "^2.1.0",
|
||||
"@chakra-ui/react": "^2.8.0",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@getalby/bitcoin-connect-react": "^1.1.0",
|
||||
|
25
src/app.tsx
25
src/app.tsx
@ -55,6 +55,10 @@ import BadgesView from "./views/badges";
|
||||
import BadgesBrowseView from "./views/badges/browse";
|
||||
import BadgeDetailsView from "./views/badges/badge-details";
|
||||
import UserArticlesTab from "./views/user/articles";
|
||||
import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
|
||||
import CommunitiesHomeView from "./views/communities";
|
||||
import CommunityFindByNameView from "./views/community/find-by-name";
|
||||
import CommunityView from "./views/community/index";
|
||||
|
||||
const StreamsView = React.lazy(() => import("./views/streams"));
|
||||
const StreamView = React.lazy(() => import("./views/streams/stream"));
|
||||
@ -177,6 +181,17 @@ const router = createHashRouter([
|
||||
{ path: ":addr", element: <ListDetailsView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "communities",
|
||||
element: <CommunitiesHomeView />,
|
||||
},
|
||||
{
|
||||
path: "c/:community",
|
||||
children: [
|
||||
{ path: "", element: <CommunityFindByNameView /> },
|
||||
{ path: ":pubkey", element: <CommunityView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "goals",
|
||||
children: [
|
||||
@ -217,9 +232,11 @@ const router = createHashRouter([
|
||||
|
||||
export const App = () => (
|
||||
<ErrorBoundary>
|
||||
<Global styles={overrideReactTextareaAutocompleteStyles} />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
<DrawerSubViewProvider parentRouter={router}>
|
||||
<Global styles={overrideReactTextareaAutocompleteStyles} />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</DrawerSubViewProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
Tr,
|
||||
Th,
|
||||
Flex,
|
||||
ButtonProps,
|
||||
} from "@chakra-ui/react";
|
||||
import relayPoolService from "../services/relay-pool";
|
||||
import { useInterval } from "react-use";
|
||||
@ -27,7 +28,7 @@ import { RelayFavicon } from "./relay-favicon";
|
||||
import relayScoreboardService from "../services/relay-scoreboard";
|
||||
import { RelayScoreBreakdown } from "./relay-score-breakdown";
|
||||
|
||||
export const ConnectedRelays = () => {
|
||||
export const ConnectedRelays = ({ ...props }: Omit<ButtonProps, "children">) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [relays, setRelays] = useState<Relay[]>(relayPoolService.getRelays());
|
||||
const sortedRelays = useMemo(() => relayScoreboardService.getRankedRelays(relays.map((r) => r.url)), [relays]);
|
||||
@ -40,8 +41,8 @@ export const ConnectedRelays = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="link" onClick={onOpen} leftIcon={<RelayIcon />}>
|
||||
{connected.length} relays connected
|
||||
<Button onClick={onOpen} leftIcon={<RelayIcon />} {...props}>
|
||||
connected to {connected.length} relays
|
||||
</Button>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="5xl">
|
||||
<ModalOverlay />
|
||||
|
@ -0,0 +1,49 @@
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Box, Card, CardProps, Center, Flex, Heading, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { UserAvatarLink } from "../../../components/user-avatar-link";
|
||||
import { UserLink } from "../../../components/user-link";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities";
|
||||
import CommunityDescription from "../../../views/communities/components/community-description";
|
||||
|
||||
export default function EmbeddedCommunity({
|
||||
community,
|
||||
...props
|
||||
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
|
||||
const image = getCommunityImage(community);
|
||||
|
||||
return (
|
||||
<Card as={LinkBox} variant="outline" gap="2" overflow="hidden" {...props}>
|
||||
{image ? (
|
||||
<Box
|
||||
backgroundImage={getCommunityImage(community)}
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
aspectRatio={3 / 1}
|
||||
/>
|
||||
) : (
|
||||
<Center aspectRatio={4 / 1} fontWeight="bold" fontSize="2xl">
|
||||
{getCommunityName(community)}
|
||||
</Center>
|
||||
)}
|
||||
<Flex direction="column" flex={1} px="2" pb="2">
|
||||
<Flex wrap="wrap" gap="2" alignItems="center">
|
||||
<Heading size="lg">
|
||||
<LinkOverlay
|
||||
as={RouterLink}
|
||||
to={`/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`}
|
||||
>
|
||||
{getCommunityName(community)}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text>Created by:</Text>
|
||||
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
|
||||
</Flex>
|
||||
<CommunityDescription community={community} maxLength={128} flex={1} />
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { Button, Card, CardBody, CardHeader, CardProps, Spacer, useDisclosure } from "@chakra-ui/react";
|
||||
import { MouseEventHandler, useCallback } from "react";
|
||||
import { Card, CardProps, Flex, LinkBox, LinkOverlay, Spacer } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { NoteContents } from "../../note/note-contents";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { UserAvatarLink } from "../../user-avatar-link";
|
||||
import { UserLink } from "../../user-link";
|
||||
@ -10,30 +11,46 @@ import appSettings from "../../../services/settings/app-settings";
|
||||
import EventVerificationIcon from "../../event-verification-icon";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import { NoteLink } from "../../note-link";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon } from "../../icons";
|
||||
import Timestamp from "../../timestamp";
|
||||
import { getSharableEventAddress } from "../../../helpers/nip19";
|
||||
import { InlineNoteContent } from "../../note/inline-note-content";
|
||||
import { useNavigateInDrawer } from "../../../providers/drawer-sub-view-provider";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
const HoverLinkOverlay = styled(LinkOverlay)`
|
||||
&:hover:before {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
`;
|
||||
|
||||
export default function EmbeddedNote({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
|
||||
const { showSignatureVerification } = useSubject(appSettings);
|
||||
const expand = useDisclosure();
|
||||
const navigate = useNavigateInDrawer();
|
||||
const to = `/n/${getSharableEventAddress(event)}`;
|
||||
|
||||
const handleClick = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
navigate(to);
|
||||
},
|
||||
[navigate, to],
|
||||
);
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<Card {...props}>
|
||||
<CardHeader padding="2" display="flex" gap="2" alignItems="center" flexWrap="wrap">
|
||||
<Card as={LinkBox} {...props}>
|
||||
<Flex p="2" gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={event.pubkey} size="xs" />
|
||||
<UserLink pubkey={event.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
|
||||
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
|
||||
<Button size="sm" onClick={expand.onToggle} leftIcon={expand.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}>
|
||||
Expand
|
||||
</Button>
|
||||
<HoverLinkOverlay as={RouterLink} to={to} onClick={handleClick} />
|
||||
<Spacer />
|
||||
{showSignatureVerification && <EventVerificationIcon event={event} />}
|
||||
<NoteLink noteId={event.id} color="current" whiteSpace="nowrap">
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
</NoteLink>
|
||||
</CardHeader>
|
||||
<CardBody p="0">{expand.isOpen && <NoteContents px="2" event={event} />}</CardBody>
|
||||
</Flex>
|
||||
<InlineNoteContent px="2" event={event} maxLength={96} />
|
||||
</Card>
|
||||
</TrustProvider>
|
||||
);
|
||||
|
@ -21,6 +21,8 @@ import EmbeddedList from "./event-types/embedded-list";
|
||||
import EmbeddedArticle from "./event-types/embedded-article";
|
||||
import EmbeddedBadge from "./event-types/embedded-badge";
|
||||
import EmbeddedStreamMessage from "./event-types/embedded-stream-message";
|
||||
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
||||
import EmbeddedCommunity from "./event-types/embedded-community";
|
||||
|
||||
export type EmbedProps = {
|
||||
goalProps?: EmbeddedGoalOptions;
|
||||
@ -49,6 +51,8 @@ export function EmbedEvent({
|
||||
return <EmbeddedBadge badge={event} {...cardProps} />;
|
||||
case STREAM_CHAT_MESSAGE_KIND:
|
||||
return <EmbeddedStreamMessage message={event} {...cardProps} />;
|
||||
case COMMUNITY_DEFINITION_KIND:
|
||||
return <EmbeddedCommunity community={event} {...cardProps} />;
|
||||
}
|
||||
|
||||
return <EmbeddedUnknown event={event} {...cardProps} />;
|
||||
@ -57,12 +61,12 @@ export function EmbedEvent({
|
||||
export function EmbedEventPointer({ pointer, ...props }: { pointer: DecodeResult } & EmbedProps) {
|
||||
switch (pointer.type) {
|
||||
case "note": {
|
||||
const { event } = useSingleEvent(pointer.data);
|
||||
const event = useSingleEvent(pointer.data);
|
||||
if (event === undefined) return <NoteLink noteId={pointer.data} />;
|
||||
return <EmbedEvent event={event} {...props} />;
|
||||
}
|
||||
case "nevent": {
|
||||
const { event } = useSingleEvent(pointer.data.id, pointer.data.relays);
|
||||
const event = useSingleEvent(pointer.data.id, pointer.data.relays);
|
||||
if (event === undefined) return <NoteLink noteId={pointer.data.id} />;
|
||||
return <EmbedEvent event={event} {...props} />;
|
||||
}
|
||||
|
@ -385,3 +385,45 @@ export const BadgeIcon = createIcon({
|
||||
d: "M17 15.2454V22.1169C17 22.393 16.7761 22.617 16.5 22.617C16.4094 22.617 16.3205 22.5923 16.2428 22.5457L12 20L7.75725 22.5457C7.52046 22.6877 7.21333 22.6109 7.07125 22.3742C7.02463 22.2964 7 22.2075 7 22.1169V15.2454C5.17107 13.7793 4 11.5264 4 9C4 4.58172 7.58172 1 12 1C16.4183 1 20 4.58172 20 9C20 11.5264 18.8289 13.7793 17 15.2454ZM9 16.4185V19.4676L12 17.6676L15 19.4676V16.4185C14.0736 16.7935 13.0609 17 12 17C10.9391 17 9.92643 16.7935 9 16.4185ZM12 15C15.3137 15 18 12.3137 18 9C18 5.68629 15.3137 3 12 3C8.68629 3 6 5.68629 6 9C6 12.3137 8.68629 15 12 15Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const DrawerIcon = createIcon({
|
||||
displayName: "DrawerIcon",
|
||||
d: "M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM8 5H4V19H8V5ZM10 5V19H20V5H10Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const UnmuteIcon = createIcon({
|
||||
displayName: "UnmuteIcon",
|
||||
d: "M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const MuteIcon = createIcon({
|
||||
displayName: "MuteIcon",
|
||||
d: "M17.8827 19.2968C16.1814 20.3755 14.1638 21.0002 12.0003 21.0002C6.60812 21.0002 2.12215 17.1204 1.18164 12.0002C1.61832 9.62282 2.81932 7.5129 4.52047 5.93457L1.39366 2.80777L2.80788 1.39355L22.6069 21.1925L21.1927 22.6068L17.8827 19.2968ZM5.9356 7.3497C4.60673 8.56015 3.6378 10.1672 3.22278 12.0002C4.14022 16.0521 7.7646 19.0002 12.0003 19.0002C13.5997 19.0002 15.112 18.5798 16.4243 17.8384L14.396 15.8101C13.7023 16.2472 12.8808 16.5002 12.0003 16.5002C9.51498 16.5002 7.50026 14.4854 7.50026 12.0002C7.50026 11.1196 7.75317 10.2981 8.19031 9.60442L5.9356 7.3497ZM12.9139 14.328L9.67246 11.0866C9.5613 11.3696 9.50026 11.6777 9.50026 12.0002C9.50026 13.3809 10.6196 14.5002 12.0003 14.5002C12.3227 14.5002 12.6309 14.4391 12.9139 14.328ZM20.8068 16.5925L19.376 15.1617C20.0319 14.2268 20.5154 13.1586 20.7777 12.0002C19.8603 7.94818 16.2359 5.00016 12.0003 5.00016C11.1544 5.00016 10.3329 5.11773 9.55249 5.33818L7.97446 3.76015C9.22127 3.26959 10.5793 3.00016 12.0003 3.00016C17.3924 3.00016 21.8784 6.87992 22.8189 12.0002C22.5067 13.6998 21.8038 15.2628 20.8068 16.5925ZM11.7229 7.50857C11.8146 7.50299 11.9071 7.50016 12.0003 7.50016C14.4855 7.50016 16.5003 9.51488 16.5003 12.0002C16.5003 12.0933 16.4974 12.1858 16.4919 12.2775L11.7229 7.50857Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const AppearanceIcon = createIcon({
|
||||
displayName: "AppearanceIcon",
|
||||
d: "M12 2C17.5222 2 22 5.97778 22 10.8889C22 13.9556 19.5111 16.4444 16.4444 16.4444H14.4778C13.5556 16.4444 12.8111 17.1889 12.8111 18.1111C12.8111 18.5333 12.9778 18.9222 13.2333 19.2111C13.5 19.5111 13.6667 19.9 13.6667 20.3333C13.6667 21.2556 12.9 22 12 22C6.47778 22 2 17.5222 2 12C2 6.47778 6.47778 2 12 2ZM10.8111 18.1111C10.8111 16.0843 12.451 14.4444 14.4778 14.4444H16.4444C18.4065 14.4444 20 12.851 20 10.8889C20 7.1392 16.4677 4 12 4C7.58235 4 4 7.58235 4 12C4 16.19 7.2226 19.6285 11.324 19.9718C10.9948 19.4168 10.8111 18.7761 10.8111 18.1111ZM7.5 12C6.67157 12 6 11.3284 6 10.5C6 9.67157 6.67157 9 7.5 9C8.32843 9 9 9.67157 9 10.5C9 11.3284 8.32843 12 7.5 12ZM16.5 12C15.6716 12 15 11.3284 15 10.5C15 9.67157 15.6716 9 16.5 9C17.3284 9 18 9.67157 18 10.5C18 11.3284 17.3284 12 16.5 12ZM12 9C11.1716 9 10.5 8.32843 10.5 7.5C10.5 6.67157 11.1716 6 12 6C12.8284 6 13.5 6.67157 13.5 7.5C13.5 8.32843 12.8284 9 12 9Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const DatabaseIcon = createIcon({
|
||||
displayName: "DatabaseIcon",
|
||||
d: "M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const PerformanceIcon = createIcon({
|
||||
displayName: "PerformanceIcon",
|
||||
d: "M20 13C20 15.2091 19.1046 17.2091 17.6569 18.6569L19.0711 20.0711C20.8807 18.2614 22 15.7614 22 13 22 7.47715 17.5228 3 12 3 6.47715 3 2 7.47715 2 13 2 15.7614 3.11929 18.2614 4.92893 20.0711L6.34315 18.6569C4.89543 17.2091 4 15.2091 4 13 4 8.58172 7.58172 5 12 5 16.4183 5 20 8.58172 20 13ZM15.293 8.29297 10.793 12.793 12.2072 14.2072 16.7072 9.70718 15.293 8.29297Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
export const CommunityIcon = createIcon({
|
||||
displayName: "CommunityIcon",
|
||||
d: "M9.55 11.5C8.30736 11.5 7.3 10.4926 7.3 9.25C7.3 8.00736 8.30736 7 9.55 7C10.7926 7 11.8 8.00736 11.8 9.25C11.8 10.4926 10.7926 11.5 9.55 11.5ZM10 19.748V16.4C10 15.9116 10.1442 15.4627 10.4041 15.0624C10.1087 15.0213 9.80681 15 9.5 15C7.93201 15 6.49369 15.5552 5.37091 16.4797C6.44909 18.0721 8.08593 19.2553 10 19.748ZM4.45286 14.66C5.86432 13.6168 7.61013 13 9.5 13C10.5435 13 11.5431 13.188 12.4667 13.5321C13.3447 13.1888 14.3924 13 15.5 13C17.1597 13 18.6849 13.4239 19.706 14.1563C19.8976 13.4703 20 12.7471 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 12.9325 4.15956 13.8278 4.45286 14.66ZM18.8794 16.0859C18.4862 15.5526 17.1708 15 15.5 15C13.4939 15 12 15.7967 12 16.4V20C14.9255 20 17.4843 18.4296 18.8794 16.0859ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM15.5 12.5C14.3954 12.5 13.5 11.6046 13.5 10.5C13.5 9.39543 14.3954 8.5 15.5 8.5C16.6046 8.5 17.5 9.39543 17.5 10.5C17.5 11.6046 16.6046 12.5 15.5 12.5Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { Avatar, Button, Flex, FlexProps, Heading, IconButton, LinkOverlay, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import accountService from "../../services/account";
|
||||
import { ConnectedRelays } from "../connected-relays";
|
||||
import { EditIcon, LogoutIcon } from "../icons";
|
||||
import ProfileLink from "./profile-link";
|
||||
import ProfileButton from "./profile-button";
|
||||
import AccountSwitcher from "./account-switcher";
|
||||
import { useContext } from "react";
|
||||
import { PostModalContext } from "../../providers/post-modal-provider";
|
||||
import PublishLog from "../publish-log";
|
||||
import NavItems from "./nav-items";
|
||||
@ -21,7 +21,7 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
gap="2"
|
||||
direction="column"
|
||||
width="15rem"
|
||||
pt="2"
|
||||
p="2"
|
||||
alignItems="stretch"
|
||||
flexShrink={0}
|
||||
h="100vh"
|
||||
@ -35,23 +35,38 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
<Heading size="md">noStrudel</Heading>
|
||||
</Flex>
|
||||
<Flex gap="2" overflow="hidden">
|
||||
<ProfileLink />
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria-label="New note"
|
||||
title="New note"
|
||||
w="3rem"
|
||||
h="3rem"
|
||||
fontSize="1.5rem"
|
||||
colorScheme="brand"
|
||||
onClick={() => openModal()}
|
||||
flexShrink={0}
|
||||
/>
|
||||
{account ? (
|
||||
<>
|
||||
<ProfileButton />
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria-label="New note"
|
||||
title="New note"
|
||||
w="3rem"
|
||||
h="3rem"
|
||||
fontSize="1.5rem"
|
||||
colorScheme="brand"
|
||||
onClick={() => openModal()}
|
||||
flexShrink={0}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button as={RouterLink} to="/login" state={{ from: location.pathname }} colorScheme="brand" w="full">
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
<AccountSwitcher />
|
||||
<NavItems />
|
||||
{account && (
|
||||
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => accountService.logout()}
|
||||
leftIcon={<LogoutIcon />}
|
||||
variant="link"
|
||||
justifyContent="flex-start"
|
||||
pl="2"
|
||||
py="2"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
@ -60,9 +75,8 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
Readonly Mode
|
||||
</Text>
|
||||
)}
|
||||
<ConnectedRelays />
|
||||
</Flex>
|
||||
<PublishLog overflowY="auto" minH="15rem" />
|
||||
<PublishLog overflowY="auto" minH="15rem" my="4" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Container, Flex, useBreakpointValue } from "@chakra-ui/react";
|
||||
import { Container, Flex, Spacer, useBreakpointValue } from "@chakra-ui/react";
|
||||
import { ErrorBoundary } from "../error-boundary";
|
||||
|
||||
import { ReloadPrompt } from "../reload-prompt";
|
||||
@ -12,11 +12,23 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<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" overflow="hidden" pb={isMobile ? "14" : 0} minH="50vh">
|
||||
<Flex direction={{ base: "column", md: "row" }}>
|
||||
<Spacer display={["none", null, "block"]} />
|
||||
{!isMobile && <DesktopSideNav position="sticky" top="0" flexShrink={0} />}
|
||||
<Container
|
||||
// set base to "md" so that when layout switches to column it is full width
|
||||
size={{ base: "md", md: "md", lg: "lg", xl: "xl", "2xl": "2xl" }}
|
||||
display="flex"
|
||||
flexGrow={1}
|
||||
padding="0"
|
||||
flexDirection="column"
|
||||
mx="0"
|
||||
pb={isMobile ? "14" : 0}
|
||||
minH="50vh"
|
||||
overflow="hidden"
|
||||
>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Flex>
|
||||
</Container>
|
||||
{isMobile && (
|
||||
<MobileBottomNav
|
||||
position="fixed"
|
||||
@ -27,7 +39,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
zIndex={10}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Spacer display={["none", null, "block"]} />
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -13,7 +13,6 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { ConnectedRelays } from "../connected-relays";
|
||||
import { LogoutIcon } from "../icons";
|
||||
import { UserAvatar } from "../user-avatar";
|
||||
import { UserLink } from "../user-link";
|
||||
@ -44,19 +43,26 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<DrawerBody padding={0} overflowY="auto" overflowX="hidden">
|
||||
<AccountSwitcher />
|
||||
{account && <AccountSwitcher />}
|
||||
<Flex direction="column" gap="2" padding="2">
|
||||
<NavItems isInDrawer />
|
||||
{account ? (
|
||||
<Button onClick={() => accountService.logout()} leftIcon={<LogoutIcon />} justifyContent="flex-start">
|
||||
Logout
|
||||
</Button>
|
||||
) : (
|
||||
{!account && (
|
||||
<Button as={RouterLink} to="/login" colorScheme="brand">
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
<ConnectedRelays />
|
||||
<NavItems />
|
||||
{account && (
|
||||
<Button
|
||||
onClick={() => accountService.logout()}
|
||||
leftIcon={<LogoutIcon />}
|
||||
justifyContent="flex-start"
|
||||
variant="link"
|
||||
pl="2"
|
||||
py="2"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AbsoluteCenter, Box, Button, ButtonProps, Divider, Text } from "@chakra-ui/react";
|
||||
import { useLoaderData, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
BadgeIcon,
|
||||
ChatIcon,
|
||||
CommunityIcon,
|
||||
EmojiIcon,
|
||||
FeedIcon,
|
||||
GoalIcon,
|
||||
@ -14,56 +15,147 @@ import {
|
||||
SettingsIcon,
|
||||
ToolsIcon,
|
||||
} from "../icons";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
|
||||
export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean }) {
|
||||
export default function NavItems() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const account = useCurrentAccount();
|
||||
|
||||
const buttonProps: ButtonProps = {
|
||||
py: "2",
|
||||
pl: "2",
|
||||
justifyContent: "flex-start",
|
||||
variant: "link",
|
||||
};
|
||||
|
||||
let active = "notes";
|
||||
if (location.pathname.startsWith("/notifications")) active = "notifications";
|
||||
else if (location.pathname.startsWith("/dm")) active = "dm";
|
||||
else if (location.pathname.startsWith("/streams")) active = "streams";
|
||||
else if (location.pathname.startsWith("/relays")) active = "relays";
|
||||
else if (location.pathname.startsWith("/lists")) active = "lists";
|
||||
else if (location.pathname.startsWith("/communities")) active = "communities";
|
||||
else if (location.pathname.startsWith("/goals")) active = "goals";
|
||||
else if (location.pathname.startsWith("/badges")) active = "badges";
|
||||
else if (location.pathname.startsWith("/emojis")) active = "emojis";
|
||||
else if (location.pathname.startsWith("/settings")) active = "settings";
|
||||
else if (location.pathname.startsWith("/tools")) active = "tools";
|
||||
else if (location.pathname.startsWith("/search")) active = "search";
|
||||
else if (location.pathname.startsWith("/t/")) active = "search";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => navigate("/")} leftIcon={<FeedIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => navigate("/")}
|
||||
leftIcon={<FeedIcon />}
|
||||
colorScheme={active === "notes" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Notes
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />} justifyContent="flex-start">
|
||||
Notifications
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/dm")} leftIcon={<ChatIcon />} justifyContent="flex-start">
|
||||
Messages
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/search")} leftIcon={<SearchIcon />} justifyContent="flex-start">
|
||||
{account && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate("/notifications")}
|
||||
leftIcon={<NotificationIcon />}
|
||||
colorScheme={active === "notifications" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Notifications
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/dm")}
|
||||
leftIcon={<ChatIcon />}
|
||||
colorScheme={active === "dm" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Messages
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => navigate("/search")}
|
||||
leftIcon={<SearchIcon />}
|
||||
colorScheme={active === "search" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => navigate("/relays")}
|
||||
leftIcon={<RelayIcon />}
|
||||
colorScheme={active === "relays" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Relays
|
||||
</Button>
|
||||
<Box position="relative" py="4">
|
||||
<Divider />
|
||||
<AbsoluteCenter
|
||||
backgroundColor={isInDrawer ? "var(--drawer-bg)" : "var(--chakra-colors-chakra-body-bg)"}
|
||||
px="2"
|
||||
>
|
||||
Other Stuff
|
||||
</AbsoluteCenter>
|
||||
</Box>
|
||||
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />} justifyContent="flex-start">
|
||||
<Text position="relative" py="2" color="GrayText">
|
||||
Other Stuff
|
||||
</Text>
|
||||
<Button
|
||||
onClick={() => navigate("/streams")}
|
||||
leftIcon={<LiveStreamIcon />}
|
||||
colorScheme={active === "streams" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Streams
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/lists")} leftIcon={<ListIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => navigate("/lists")}
|
||||
leftIcon={<ListIcon />}
|
||||
colorScheme={active === "lists" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Lists
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/goals")} leftIcon={<GoalIcon />} justifyContent="flex-start">
|
||||
{/* <Button
|
||||
onClick={() => navigate("/communities")}
|
||||
leftIcon={<CommunityIcon />}
|
||||
colorScheme={active === "communities" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Communities
|
||||
</Button> */}
|
||||
<Button
|
||||
onClick={() => navigate("/goals")}
|
||||
leftIcon={<GoalIcon />}
|
||||
colorScheme={active === "goals" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Goals
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/badges")} leftIcon={<BadgeIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => navigate("/badges")}
|
||||
leftIcon={<BadgeIcon />}
|
||||
colorScheme={active === "badges" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Badges
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => navigate("/emojis")}
|
||||
leftIcon={<EmojiIcon />}
|
||||
colorScheme={active === "emojis" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Emojis
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/tools")} leftIcon={<ToolsIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => navigate("/tools")}
|
||||
leftIcon={<ToolsIcon />}
|
||||
colorScheme={active === "tools" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Tools
|
||||
</Button>
|
||||
<Divider my="2" />
|
||||
<Button onClick={() => navigate("/settings")} leftIcon={<SettingsIcon />} justifyContent="flex-start">
|
||||
<Button
|
||||
onClick={() => navigate("/settings")}
|
||||
leftIcon={<SettingsIcon />}
|
||||
colorScheme={active === "settings" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Box, Button, ButtonProps, LinkBox, LinkOverlay } from "@chakra-ui/react";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import { LinkBox, LinkOverlay } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { UserAvatar } from "../user-avatar";
|
||||
@ -7,7 +7,7 @@ import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
import { getUserDisplayName } from "../../helpers/user-metadata";
|
||||
|
||||
function ProfileButton() {
|
||||
export default function ProfileButton() {
|
||||
const account = useCurrentAccount()!;
|
||||
const metadata = useUserMetadata(account.pubkey);
|
||||
|
||||
@ -37,16 +37,3 @@ function ProfileButton() {
|
||||
</LinkBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfileLink() {
|
||||
const account = useCurrentAccount();
|
||||
const location = useLocation();
|
||||
|
||||
if (account) return <ProfileButton />;
|
||||
|
||||
return (
|
||||
<Button as={RouterLink} to="/login" state={{ from: location.pathname }} colorScheme="brand" isTruncated>
|
||||
Login
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -18,8 +18,8 @@ import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import useUserLists from "../../../hooks/use-user-lists";
|
||||
import {
|
||||
NOTE_LIST_KIND,
|
||||
draftAddEvent,
|
||||
draftRemoveEvent,
|
||||
listAddEvent,
|
||||
listRemoveEvent,
|
||||
getEventsFromList,
|
||||
getListName,
|
||||
} from "../../../helpers/nostr/lists";
|
||||
@ -29,6 +29,7 @@ import clientRelaysService from "../../../services/client-relays";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import { BookmarkIcon, BookmarkedIcon, PlusCircleIcon } from "../../icons";
|
||||
import NewListModal from "../../../views/lists/components/new-list-modal";
|
||||
import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
|
||||
|
||||
export default function BookmarkButton({ event, ...props }: { event: NostrEvent } & Omit<IconButtonProps, "icon">) {
|
||||
const toast = useToast();
|
||||
@ -55,20 +56,22 @@ export default function BookmarkButton({ event, ...props }: { event: NostrEvent
|
||||
);
|
||||
|
||||
if (addToList) {
|
||||
const draft = draftAddEvent(addToList, event.id);
|
||||
const draft = listAddEvent(addToList, event.id);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Add to list", writeRelays, signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
} else if (removeFromList) {
|
||||
const draft = draftRemoveEvent(removeFromList, event.id);
|
||||
const draft = listRemoveEvent(removeFromList, event.id);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Remove from list", writeRelays, signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
[lists, event.id],
|
||||
[lists, event.id, requestSignature],
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -38,87 +38,95 @@ import NoteReactions from "./components/note-reactions";
|
||||
import ReplyForm from "../../views/note/components/reply-form";
|
||||
import { getReferences } from "../../helpers/nostr/events";
|
||||
import Timestamp from "../timestamp";
|
||||
import OpenInDrawerButton from "../open-in-drawer-button";
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
|
||||
export type NoteProps = Omit<CardProps, "children"> & {
|
||||
event: NostrEvent;
|
||||
variant?: CardProps["variant"];
|
||||
showReplyButton?: boolean;
|
||||
hideDrawerButton?: boolean;
|
||||
};
|
||||
export const Note = React.memo(({ event, variant = "outline", showReplyButton, ...props }: NoteProps) => {
|
||||
const account = useCurrentAccount();
|
||||
const { showReactions, showSignatureVerification } = useSubject(appSettings);
|
||||
const replyForm = useDisclosure();
|
||||
export const Note = React.memo(
|
||||
({ event, variant = "outline", showReplyButton, hideDrawerButton, ...props }: NoteProps) => {
|
||||
const account = useCurrentAccount();
|
||||
const { showReactions, showSignatureVerification } = useSubject(appSettings);
|
||||
const replyForm = useDisclosure();
|
||||
|
||||
// if there is a parent intersection observer, register this card
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
// if there is a parent intersection observer, register this card
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
// find mostr external link
|
||||
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
|
||||
// find mostr external link
|
||||
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
|
||||
|
||||
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
|
||||
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
const reactionButtons = showReactions && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="xs" />;
|
||||
const reactionButtons = showReactions && <NoteReactions event={event} flexWrap="wrap" variant="ghost" size="xs" />;
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<ExpandProvider>
|
||||
<Card variant={variant} ref={ref} data-event-id={event.id} {...props}>
|
||||
<CardHeader padding="2">
|
||||
<Flex flex="1" gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
|
||||
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
|
||||
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
|
||||
<Flex grow={1} />
|
||||
{showSignatureVerification && <EventVerificationIcon event={event} />}
|
||||
<NoteLink noteId={event.id} whiteSpace="nowrap" color="current">
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
</NoteLink>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody p="0">
|
||||
<NoteContentWithWarning event={event} />
|
||||
</CardBody>
|
||||
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
|
||||
{showReactionsOnNewLine && reactionButtons}
|
||||
<Flex gap="2" w="full" alignItems="center">
|
||||
<ButtonGroup size="xs" variant="ghost" isDisabled={account?.readonly ?? true}>
|
||||
{showReplyButton && (
|
||||
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<ExpandProvider>
|
||||
<Card variant={variant} ref={ref} data-event-id={event.id} {...props}>
|
||||
<CardHeader padding="2">
|
||||
<Flex flex="1" gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
|
||||
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
|
||||
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
|
||||
<Flex grow={1} />
|
||||
{showSignatureVerification && <EventVerificationIcon event={event} />}
|
||||
{!hideDrawerButton && (
|
||||
<OpenInDrawerButton to={`/n/${getSharableEventAddress(event)}`} size="sm" variant="ghost" />
|
||||
)}
|
||||
<RepostButton event={event} />
|
||||
<QuoteRepostButton event={event} />
|
||||
<NoteZapButton event={event} />
|
||||
</ButtonGroup>
|
||||
{!showReactionsOnNewLine && reactionButtons}
|
||||
<Box flexGrow={1} />
|
||||
{externalLink && (
|
||||
<IconButton
|
||||
as={Link}
|
||||
icon={<ExternalLinkIcon />}
|
||||
aria-label="Open External"
|
||||
href={externalLink}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
<EventRelays event={event} />
|
||||
<BookmarkButton event={event} aria-label="Bookmark note" size="xs" variant="ghost" />
|
||||
<NoteMenu event={event} size="xs" variant="ghost" aria-label="More Options" />
|
||||
</Flex>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</ExpandProvider>
|
||||
{replyForm.isOpen && (
|
||||
<ReplyForm
|
||||
item={{ event, replies: [], refs: getReferences(event) }}
|
||||
onCancel={replyForm.onClose}
|
||||
onSubmitted={replyForm.onClose}
|
||||
/>
|
||||
)}
|
||||
</TrustProvider>
|
||||
);
|
||||
});
|
||||
<NoteLink noteId={event.id} whiteSpace="nowrap" color="current">
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
</NoteLink>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody p="0">
|
||||
<NoteContentWithWarning event={event} />
|
||||
</CardBody>
|
||||
<CardFooter padding="2" display="flex" gap="2" flexDirection="column" alignItems="flex-start">
|
||||
{showReactionsOnNewLine && reactionButtons}
|
||||
<Flex gap="2" w="full" alignItems="center">
|
||||
<ButtonGroup size="xs" variant="ghost" isDisabled={account?.readonly ?? true}>
|
||||
{showReplyButton && (
|
||||
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
|
||||
)}
|
||||
<RepostButton event={event} />
|
||||
<QuoteRepostButton event={event} />
|
||||
<NoteZapButton event={event} />
|
||||
</ButtonGroup>
|
||||
{!showReactionsOnNewLine && reactionButtons}
|
||||
<Box flexGrow={1} />
|
||||
{externalLink && (
|
||||
<IconButton
|
||||
as={Link}
|
||||
icon={<ExternalLinkIcon />}
|
||||
aria-label="Open External"
|
||||
href={externalLink}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
<EventRelays event={event} />
|
||||
<BookmarkButton event={event} aria-label="Bookmark note" size="xs" variant="ghost" />
|
||||
<NoteMenu event={event} size="xs" variant="ghost" aria-label="More Options" />
|
||||
</Flex>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</ExpandProvider>
|
||||
{replyForm.isOpen && (
|
||||
<ReplyForm
|
||||
item={{ event, replies: [], refs: getReferences(event) }}
|
||||
onCancel={replyForm.onClose}
|
||||
onSubmitted={replyForm.onClose}
|
||||
/>
|
||||
)}
|
||||
</TrustProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Note;
|
||||
|
42
src/components/note/inline-note-content.tsx
Normal file
42
src/components/note/inline-note-content.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../../helpers/embeds";
|
||||
import { embedNostrLinks, embedNostrMentions, embedNostrHashtags, embedEmoji, renderGenericUrl } from "../embed-types";
|
||||
import { LightboxProvider } from "../lightbox-provider";
|
||||
|
||||
function buildContents(event: NostrEvent | DraftNostrEvent) {
|
||||
let content: EmbedableContent = [event.content.trim().replace(/\n+/g, "\n")];
|
||||
|
||||
// common
|
||||
content = embedUrls(content, [renderGenericUrl]);
|
||||
|
||||
// nostr
|
||||
content = embedNostrLinks(content);
|
||||
content = embedNostrMentions(content, event);
|
||||
content = embedNostrHashtags(content, event);
|
||||
content = embedEmoji(content, event);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export type NoteContentsProps = {
|
||||
event: NostrEvent | DraftNostrEvent;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export const InlineNoteContent = React.memo(
|
||||
({ event, maxLength, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
|
||||
let content = buildContents(event);
|
||||
let truncated = maxLength !== undefined ? truncateEmbedableContent(content, maxLength) : content;
|
||||
|
||||
return (
|
||||
<LightboxProvider>
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{truncated}
|
||||
{truncated !== content ? "..." : null}
|
||||
</Box>
|
||||
</LightboxProvider>
|
||||
);
|
||||
},
|
||||
);
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../../helpers/embeds";
|
||||
import {
|
||||
embedLightningInvoice,
|
||||
embedNostrLinks,
|
||||
@ -60,11 +60,16 @@ function buildContents(event: NostrEvent | DraftNostrEvent, simpleLinks = false)
|
||||
export type NoteContentsProps = {
|
||||
event: NostrEvent | DraftNostrEvent;
|
||||
noOpenGraphLinks?: boolean;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export const NoteContents = React.memo(
|
||||
({ event, noOpenGraphLinks, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
|
||||
const content = buildContents(event, noOpenGraphLinks);
|
||||
({ event, noOpenGraphLinks, maxLength, ...props }: NoteContentsProps & Omit<BoxProps, "children">) => {
|
||||
let content = buildContents(event, noOpenGraphLinks);
|
||||
|
||||
if (maxLength !== undefined) {
|
||||
content = truncateEmbedableContent(content, maxLength);
|
||||
}
|
||||
|
||||
return (
|
||||
<LightboxProvider>
|
||||
|
@ -7,7 +7,17 @@ import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
|
||||
|
||||
import { ClipboardIcon, CodeIcon, ExternalLinkIcon, LikeIcon, RelayIcon, RepostIcon, TrashIcon } from "../icons";
|
||||
import {
|
||||
ClipboardIcon,
|
||||
CodeIcon,
|
||||
ExternalLinkIcon,
|
||||
LikeIcon,
|
||||
MuteIcon,
|
||||
RelayIcon,
|
||||
RepostIcon,
|
||||
TrashIcon,
|
||||
UnmuteIcon,
|
||||
} from "../icons";
|
||||
import NoteReactionsModal from "./note-zaps-modal";
|
||||
import NoteDebugModal from "../debug-modals/note-debug-modal";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
@ -16,11 +26,13 @@ import { useDeleteEventContext } from "../../providers/delete-event-provider";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import { handleEventFromRelay } from "../../services/event-relays";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
|
||||
|
||||
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
|
||||
const account = useCurrentAccount();
|
||||
const infoModal = useDisclosure();
|
||||
const reactionsModal = useDisclosure();
|
||||
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
|
||||
|
||||
const { deleteEvent } = useDeleteEventContext();
|
||||
|
||||
@ -29,13 +41,9 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
|
||||
const broadcast = useCallback(() => {
|
||||
const missingRelays = clientRelaysService.getWriteUrls();
|
||||
|
||||
const pub = new NostrPublishAction("Broadcast", missingRelays, event, 5000);
|
||||
|
||||
pub.onResult.subscribe((result) => {
|
||||
if (result.status) {
|
||||
handleEventFromRelay(result.relay, event);
|
||||
}
|
||||
if (result.status) handleEventFromRelay(result.relay, event);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -44,14 +52,16 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
|
||||
Zaps/Reactions
|
||||
</MenuItem>
|
||||
{address && (
|
||||
<MenuItem onClick={() => window.open(buildAppSelectUrl(address), "_blank")} icon={<ExternalLinkIcon />}>
|
||||
View in app...
|
||||
</MenuItem>
|
||||
)}
|
||||
{account?.pubkey !== event.pubkey && (
|
||||
<MenuItem onClick={isMuted ? unmute : mute} icon={isMuted ? <UnmuteIcon /> : <MuteIcon />} color="red.500">
|
||||
{isMuted ? "Unmute User" : "Mute User"}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => copyToClipboard("nostr:" + address)} icon={<RepostIcon />}>
|
||||
Copy Share Link
|
||||
</MenuItem>
|
||||
@ -71,6 +81,9 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
|
||||
Zaps/Reactions
|
||||
</MenuItem>
|
||||
</MenuIconButton>
|
||||
|
||||
{infoModal.isOpen && (
|
||||
|
@ -78,8 +78,6 @@ export default function NoteReactionsModal({
|
||||
const reactions = useEventReactions(noteId, [], true) ?? [];
|
||||
const [selected, setSelected] = useState("zaps");
|
||||
|
||||
console.log(reactions);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
|
@ -47,13 +47,13 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
|
||||
mx={isVertical ? "auto" : 0}
|
||||
/>
|
||||
)}
|
||||
<Box p="2">
|
||||
<Heading size="sm">
|
||||
<Box p="2" overflow="hidden">
|
||||
<Heading size="sm" isTruncated>
|
||||
<LinkOverlay href={url.toString()} isExternal>
|
||||
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
|
||||
<Text noOfLines={5}>{data.ogDescription || data.dcDescription}</Text>
|
||||
{link}
|
||||
</Box>
|
||||
</LinkBox>
|
||||
|
19
src/components/open-in-drawer-button.tsx
Normal file
19
src/components/open-in-drawer-button.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { IconButton, IconButtonProps } from "@chakra-ui/react";
|
||||
import { To } from "react-router-dom";
|
||||
|
||||
import { DrawerIcon } from "./icons";
|
||||
import { useNavigateInDrawer } from "../providers/drawer-sub-view-provider";
|
||||
|
||||
export default function OpenInDrawerButton({ to, ...props }: Omit<IconButtonProps, "aria-label"> & { to: To }) {
|
||||
const navigate = useNavigateInDrawer();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<DrawerIcon />}
|
||||
aria-label="Open in drawer"
|
||||
title="Open in drawer"
|
||||
onClick={() => navigate(to)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
@ -30,7 +30,11 @@ import { UserAvatarStack } from "../compact-user-stack";
|
||||
import MagicTextArea from "../magic-textarea";
|
||||
import { useContextEmojis } from "../../providers/emoji-provider";
|
||||
|
||||
export default function PostModal({ isOpen, onClose, initContent = '' }: Omit<ModalProps, "children"> & {initContent?: string}) {
|
||||
export default function PostModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
initContent = "",
|
||||
}: Omit<ModalProps, "children"> & { initContent?: string }) {
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
const writeRelays = useWriteRelayUrls();
|
||||
|
@ -10,11 +10,10 @@ import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { STREAM_KIND } from "../../../helpers/nostr/stream";
|
||||
import StreamNote from "./stream-note";
|
||||
import { ErrorBoundary } from "../../error-boundary";
|
||||
import RelayCard from "../../../views/relays/components/relay-card";
|
||||
import { safeRelayUrl } from "../../../helpers/url";
|
||||
import EmbeddedArticle from "../../embed-event/event-types/embedded-article";
|
||||
import { isReply } from "../../../helpers/nostr/events";
|
||||
import ReplyNote from "./reply-note";
|
||||
import RelayRecommendation from "./relay-recommendation";
|
||||
|
||||
function RenderEvent({ event }: { event: NostrEvent }) {
|
||||
let content: ReactNode | null = null;
|
||||
@ -31,9 +30,8 @@ function RenderEvent({ event }: { event: NostrEvent }) {
|
||||
case STREAM_KIND:
|
||||
content = <StreamNote event={event} />;
|
||||
break;
|
||||
case 2:
|
||||
const safeUrl = safeRelayUrl(event.content);
|
||||
content = safeUrl ? <RelayCard url={safeUrl} /> : null;
|
||||
case Kind.RecommendRelay:
|
||||
content = <RelayRecommendation event={event} />;
|
||||
break;
|
||||
default:
|
||||
content = <Text>Unknown event kind: {event.kind}</Text>;
|
||||
|
@ -0,0 +1,28 @@
|
||||
import { useRef } from "react";
|
||||
import { Flex, Text } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { UserAvatar } from "../../user-avatar";
|
||||
import { UserLink } from "../../user-link";
|
||||
import RelayCard from "../../../views/relays/components/relay-card";
|
||||
import { safeRelayUrl } from "../../../helpers/url";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
|
||||
export default function RelayRecommendation({ event }: { event: NostrEvent }) {
|
||||
const safeUrl = safeRelayUrl(event.content);
|
||||
|
||||
// if there is a parent intersection observer, register this card
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" ref={ref}>
|
||||
<Flex gap="2">
|
||||
<UserAvatar pubkey={event.pubkey} size="xs" alignItems="center" />
|
||||
<UserLink pubkey={event.pubkey} />
|
||||
<Text>Recommended relay:</Text>
|
||||
</Flex>
|
||||
{safeUrl ? <RelayCard url={safeUrl} /> : event.content}
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -12,7 +12,7 @@ import { UserLink } from "../../user-link";
|
||||
|
||||
function ReplyNote({ event }: { event: NostrEvent }) {
|
||||
const refs = getReferences(event);
|
||||
const { event: parent } = useSingleEvent(refs.replyId);
|
||||
const parent = useSingleEvent(refs.replyId);
|
||||
|
||||
// if there is a parent intersection observer, register this card
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { useRef } from "react";
|
||||
import { Flex, Heading, SkeletonText, Text } from "@chakra-ui/react";
|
||||
import { useAsync } from "react-use";
|
||||
import singleEventService from "../../../services/single-event";
|
||||
import { validateEvent } from "nostr-tools";
|
||||
|
||||
import { isETag, NostrEvent } from "../../../types/nostr-event";
|
||||
import { ErrorFallback } from "../../error-boundary";
|
||||
import { Note } from "../../note";
|
||||
import { NoteMenu } from "../../note/note-menu";
|
||||
import { UserAvatar } from "../../user-avatar";
|
||||
@ -13,7 +12,7 @@ import { TrustProvider } from "../../../providers/trust";
|
||||
import { safeJson } from "../../../helpers/parse";
|
||||
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
import { validateEvent } from "nostr-tools";
|
||||
import useSingleEvent from "../../../hooks/use-single-event";
|
||||
|
||||
function parseHardcodedNoteContent(event: NostrEvent) {
|
||||
const json = safeJson(event.content, null);
|
||||
@ -36,16 +35,7 @@ export default function RepostNote({ event }: { event: NostrEvent }) {
|
||||
const [_, eventId, relay] = event.tags.find(isETag) ?? [];
|
||||
const readRelays = useReadRelayUrls(relay ? [relay] : []);
|
||||
|
||||
const {
|
||||
value: loadedNote,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async () => {
|
||||
if (eventId) {
|
||||
return singleEventService.requestEvent(eventId, readRelays);
|
||||
}
|
||||
return null;
|
||||
}, [event]);
|
||||
const loadedNote = useSingleEvent(eventId, readRelays);
|
||||
|
||||
const note = hardCodedNote || loadedNote;
|
||||
|
||||
@ -63,7 +53,7 @@ export default function RepostNote({ event }: { event: NostrEvent }) {
|
||||
</Text>
|
||||
<NoteMenu event={event} size="sm" variant="link" aria-label="note options" />
|
||||
</Flex>
|
||||
{loading ? <SkeletonText /> : note ? <Note event={note} showReplyButton /> : <ErrorFallback error={error} />}
|
||||
{!note ? <SkeletonText /> : <Note event={note} showReplyButton />}
|
||||
</Flex>
|
||||
</TrustProvider>
|
||||
);
|
||||
|
@ -1,11 +1,19 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
|
||||
const aDayAgo = dayjs().subtract(1, "day");
|
||||
|
||||
export default function Timestamp({ timestamp, ...props }: { timestamp: number } & Omit<BoxProps, "children">) {
|
||||
const date = dayjs.unix(timestamp);
|
||||
|
||||
return (
|
||||
<Box as="time" dateTime={date.toISOString()} title={date.format("LLL")} {...props}>
|
||||
{date.fromNow()}
|
||||
<Box
|
||||
as="time"
|
||||
dateTime={date.toISOString()}
|
||||
title={date.isBefore(aDayAgo) ? date.fromNow() : date.format("LLL")}
|
||||
{...props}
|
||||
>
|
||||
{date.isBefore(aDayAgo) ? date.format("L LT") : date.fromNow()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -14,13 +14,13 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import { ArrowDownSIcon, FollowIcon, PlusCircleIcon, UnfollowIcon } from "./icons";
|
||||
import { ArrowDownSIcon, FollowIcon, MuteIcon, PlusCircleIcon, UnfollowIcon, UnmuteIcon } from "./icons";
|
||||
import useUserLists from "../hooks/use-user-lists";
|
||||
import {
|
||||
PEOPLE_LIST_KIND,
|
||||
createEmptyContactList,
|
||||
draftAddPerson,
|
||||
draftRemovePerson,
|
||||
listAddPerson,
|
||||
listRemovePerson,
|
||||
getListName,
|
||||
getPubkeysFromList,
|
||||
isPubkeyInList,
|
||||
@ -33,6 +33,7 @@ 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";
|
||||
import useUserMuteFunctions from "../hooks/use-user-mute-functions";
|
||||
|
||||
function UsersLists({ pubkey }: { pubkey: string }) {
|
||||
const toast = useToast();
|
||||
@ -59,11 +60,11 @@ function UsersLists({ pubkey }: { pubkey: string }) {
|
||||
);
|
||||
|
||||
if (addToList) {
|
||||
const draft = draftAddPerson(addToList, pubkey);
|
||||
const draft = listAddPerson(addToList, pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Add to list", writeRelays, signed);
|
||||
} else if (removeFromList) {
|
||||
const draft = draftRemovePerson(removeFromList, pubkey);
|
||||
const draft = listRemovePerson(removeFromList, pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Remove from list", writeRelays, signed);
|
||||
}
|
||||
@ -115,23 +116,24 @@ export type UserFollowButtonProps = { pubkey: string; showLists?: boolean } & Om
|
||||
export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButtonProps) => {
|
||||
const account = useCurrentAccount()!;
|
||||
const { requestSignature } = useSigningContext();
|
||||
const contacts = useUserContactList(account?.pubkey, [], true);
|
||||
const contacts = useUserContactList(account?.pubkey, [], { ignoreCache: true });
|
||||
const { isMuted, mute, unmute } = useUserMuteFunctions(pubkey);
|
||||
|
||||
const isFollowing = isPubkeyInList(contacts, pubkey);
|
||||
const isDisabled = account?.readonly ?? true;
|
||||
|
||||
const handleFollow = useAsyncErrorHandler(async () => {
|
||||
const draft = draftAddPerson(contacts || createEmptyContactList(), pubkey);
|
||||
const draft = listAddPerson(contacts || createEmptyContactList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Follow", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
});
|
||||
}, [contacts, requestSignature]);
|
||||
const handleUnfollow = useAsyncErrorHandler(async () => {
|
||||
const draft = draftRemovePerson(contacts || createEmptyContactList(), pubkey);
|
||||
const draft = listRemovePerson(contacts || createEmptyContactList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
const pub = new NostrPublishAction("Unfollow", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
});
|
||||
}, [contacts, requestSignature]);
|
||||
|
||||
if (showLists) {
|
||||
return (
|
||||
@ -149,6 +151,16 @@ export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButt
|
||||
Follow
|
||||
</MenuItem>
|
||||
)}
|
||||
{account?.pubkey !== pubkey && (
|
||||
<MenuItem
|
||||
onClick={isMuted ? unmute : mute}
|
||||
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
|
||||
color="red.500"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{isMuted ? "Unmute" : "Mute"}
|
||||
</MenuItem>
|
||||
)}
|
||||
{account && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
|
9
src/components/vertical-page-layout.tsx
Normal file
9
src/components/vertical-page-layout.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { Flex, FlexProps } from "@chakra-ui/react";
|
||||
|
||||
export default function VerticalPageLayout({ children, ...props }: FlexProps) {
|
||||
return (
|
||||
<Flex direction="column" pt="2" pb="12" gap="2" px="2" {...props}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -92,3 +92,32 @@ export function embedUrls(content: EmbedableContent, handlers: LinkEmbedHandler[
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function truncateEmbedableContent(content: EmbedableContent, maxLength = 256) {
|
||||
let length = 0;
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const chunk = content[i];
|
||||
length += typeof chunk === "string" ? chunk.length : 8;
|
||||
|
||||
if (length > maxLength) {
|
||||
if (typeof chunk === "string") {
|
||||
const newContent = i > 0 ? content.slice(0, i) : [];
|
||||
const chunkLength = chunk.length - (length - maxLength);
|
||||
|
||||
// find the nearest newline
|
||||
const newLines = chunk.matchAll(/\n/g);
|
||||
for (const match of newLines) {
|
||||
if (match.index && match.index > chunkLength) {
|
||||
newContent.push(chunk.slice(0, match.index));
|
||||
return newContent;
|
||||
}
|
||||
}
|
||||
|
||||
// just cut the string
|
||||
newContent.push(chunk.slice(0, maxLength - length));
|
||||
return newContent;
|
||||
} else return content.slice(0, i);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
@ -47,7 +47,8 @@ export function safeDecode(str: string) {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function getPubkey(result: nip19.DecodeResult) {
|
||||
export function getPubkey(result?: nip19.DecodeResult) {
|
||||
if (!result) return;
|
||||
switch (result.type) {
|
||||
case "naddr":
|
||||
case "nprofile":
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { NostrEvent, isATag, isPTag } from "../../types/nostr-event";
|
||||
import { getPubkeysFromList } from "./lists";
|
||||
|
||||
export const PROFILE_BADGES_IDENTIFIER = "profile_badges";
|
||||
|
||||
@ -30,9 +31,7 @@ export function getBadgeThumbnails(event: NostrEvent) {
|
||||
}
|
||||
|
||||
export function getBadgeAwardPubkey(event: NostrEvent) {
|
||||
const pubkey = event.tags.find(isPTag)?.[1];
|
||||
if (!pubkey) throw new Error("Missing pubkey");
|
||||
return pubkey;
|
||||
return getPubkeysFromList(event);
|
||||
}
|
||||
export function getBadgeAwardBadge(event: NostrEvent) {
|
||||
const badgeCord = event.tags.find(isATag)?.[1];
|
||||
|
35
src/helpers/nostr/communities.ts
Normal file
35
src/helpers/nostr/communities.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NostrEvent, isDTag, isPTag } from "../../types/nostr-event";
|
||||
|
||||
export const SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER = "communities";
|
||||
export const COMMUNITY_DEFINITION_KIND = 34550;
|
||||
export const COMMUNITY_APPROVAL_KIND = 4550;
|
||||
|
||||
export function getCommunityName(community: NostrEvent) {
|
||||
const name = community.tags.find(isDTag)?.[1];
|
||||
if (!name) throw new Error("Missing name");
|
||||
return name;
|
||||
}
|
||||
|
||||
export function getCommunityMods(community: NostrEvent) {
|
||||
const mods = community.tags.filter((t) => isPTag(t) && t[1] && t[3] === "moderator").map((t) => t[1]) as string[];
|
||||
return mods;
|
||||
}
|
||||
export function getCOmmunityRelays(community: NostrEvent) {
|
||||
return community.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]) as string[];
|
||||
}
|
||||
|
||||
export function getCommunityImage(community: NostrEvent) {
|
||||
return community.tags.find((t) => t[0] === "image")?.[1];
|
||||
}
|
||||
export function getCommunityDescription(community: NostrEvent) {
|
||||
return community.tags.find((t) => t[0] === "description")?.[1];
|
||||
}
|
||||
|
||||
export function validateCommunity(community: NostrEvent) {
|
||||
try {
|
||||
getCommunityName(community);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
import { AddressPointer } from "nostr-tools/lib/nip19";
|
||||
|
||||
import { DraftNostrEvent, NostrEvent, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event";
|
||||
import { parseCoordinate } from "./events";
|
||||
|
||||
export const PEOPLE_LIST_KIND = 30000;
|
||||
export const NOTE_LIST_KIND = 30001;
|
||||
@ -11,9 +14,19 @@ export function getListName(event: NostrEvent) {
|
||||
if (event.kind === Kind.Contacts) return "Following";
|
||||
if (event.kind === PIN_LIST_KIND) return "Pins";
|
||||
if (event.kind === MUTE_LIST_KIND) return "Mute";
|
||||
return event.tags.find((t) => t[0] === "title")?.[1] || event.tags.find(isDTag)?.[1];
|
||||
return (
|
||||
event.tags.find((t) => t[0] === "name")?.[1] ||
|
||||
event.tags.find((t) => t[0] === "title")?.[1] ||
|
||||
event.tags.find(isDTag)?.[1]
|
||||
);
|
||||
}
|
||||
|
||||
export function isJunkList(event: NostrEvent) {
|
||||
const name = event.tags.find(isDTag)?.[1];
|
||||
if (!name) return false;
|
||||
if (event.kind !== PEOPLE_LIST_KIND) return false;
|
||||
return /^(chats\/([0-9a-f]{64}|null)|notifications)\/lastOpened$/.test(name);
|
||||
}
|
||||
export function isSpecialListKind(kind: number) {
|
||||
return kind === Kind.Contacts || kind === PIN_LIST_KIND || kind === MUTE_LIST_KIND;
|
||||
}
|
||||
@ -30,6 +43,20 @@ export function getReferencesFromList(event: NostrEvent) {
|
||||
export function getCoordinatesFromList(event: NostrEvent) {
|
||||
return event.tags.filter(isATag).map((t) => ({ coordinate: t[1], relay: t[2] }));
|
||||
}
|
||||
export function getParsedCordsFromList(event: NostrEvent) {
|
||||
const pointers: AddressPointer[] = [];
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (!tag[1]) continue;
|
||||
const relay = tag[2];
|
||||
const parsed = parseCoordinate(tag[1]);
|
||||
if (!parsed?.identifier) continue;
|
||||
|
||||
pointers.push({ ...parsed, identifier: parsed?.identifier, relays: relay ? [relay] : undefined });
|
||||
}
|
||||
|
||||
return pointers;
|
||||
}
|
||||
|
||||
export function isPubkeyInList(event?: NostrEvent, pubkey?: string) {
|
||||
if (!pubkey || !event) return false;
|
||||
@ -53,50 +80,63 @@ export function createEmptyMuteList(): DraftNostrEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export function draftAddPerson(list: NostrEvent | DraftNostrEvent, pubkey: string, relay?: string) {
|
||||
export function listAddPerson(list: NostrEvent | DraftNostrEvent, pubkey: string, relay?: string): DraftNostrEvent {
|
||||
if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list");
|
||||
|
||||
const draft: DraftNostrEvent = {
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: [...list.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]],
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function draftRemovePerson(list: NostrEvent | DraftNostrEvent, pubkey: string) {
|
||||
const draft: DraftNostrEvent = {
|
||||
export function listRemovePerson(list: NostrEvent | DraftNostrEvent, pubkey: string): DraftNostrEvent {
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: list.tags.filter((t) => !(t[0] === "p" && t[1] === pubkey)),
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function draftAddEvent(list: NostrEvent | DraftNostrEvent, event: string, relay?: string) {
|
||||
export function listAddEvent(list: NostrEvent | DraftNostrEvent, event: string, relay?: string): DraftNostrEvent {
|
||||
if (list.tags.some((t) => t[0] === "e" && t[1] === event)) throw new Error("event already in list");
|
||||
|
||||
const draft: DraftNostrEvent = {
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: [...list.tags, relay ? ["e", event, relay] : ["e", event]],
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function draftRemoveEvent(list: NostrEvent | DraftNostrEvent, event: string) {
|
||||
const draft: DraftNostrEvent = {
|
||||
export function listRemoveEvent(list: NostrEvent | DraftNostrEvent, event: string): DraftNostrEvent {
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: list.tags.filter((t) => !(t[0] === "e" && t[1] === event)),
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function listAddCoordinate(
|
||||
list: NostrEvent | DraftNostrEvent,
|
||||
coordinate: string,
|
||||
relay?: string,
|
||||
): DraftNostrEvent {
|
||||
if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("coordinate already in list");
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: [...list.tags, relay ? ["a", coordinate, relay] : ["a", coordinate]],
|
||||
};
|
||||
}
|
||||
|
||||
export function listRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string): DraftNostrEvent {
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: list.tags.filter((t) => !(t[0] === "a" && t[1] === coordinate)),
|
||||
};
|
||||
}
|
||||
|
@ -26,17 +26,23 @@ export type ParsedStream = {
|
||||
relays?: string[];
|
||||
};
|
||||
|
||||
export function getStreamHost(stream: NostrEvent) {
|
||||
return stream.tags.filter(isPTag)[0]?.[1] ?? stream.pubkey;
|
||||
}
|
||||
|
||||
export function parseStreamEvent(stream: NostrEvent): ParsedStream {
|
||||
const title = stream.tags.find((t) => t[0] === "title")?.[1];
|
||||
const summary = stream.tags.find((t) => t[0] === "summary")?.[1];
|
||||
const image = stream.tags.find((t) => t[0] === "image")?.[1];
|
||||
const starts = stream.tags.find((t) => t[0] === "starts")?.[1];
|
||||
const endsTag = stream.tags.find((t) => t[0] === "ends")?.[1];
|
||||
const ends = stream.tags.find((t) => t[0] === "ends")?.[1];
|
||||
const streaming = stream.tags.find((t) => t[0] === "streaming")?.[1];
|
||||
const recording = stream.tags.find((t) => t[0] === "recording")?.[1];
|
||||
const goal = stream.tags.find((t) => t[0] === "goal")?.[1];
|
||||
const identifier = stream.tags.find((t) => t[0] === "d")?.[1];
|
||||
|
||||
if (!identifier) throw new Error("missing identifier");
|
||||
|
||||
let relays = stream.tags.find((t) => t[0] === "relays");
|
||||
// remove the first "relays" element
|
||||
if (relays) {
|
||||
@ -44,12 +50,11 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
|
||||
relays.shift();
|
||||
}
|
||||
|
||||
const startTime = starts ? parseInt(starts) : stream.created_at;
|
||||
const endTime = endsTag ? parseInt(endsTag) : undefined;
|
||||
|
||||
if (!identifier) throw new Error("missing identifier");
|
||||
const startTime = starts ? parseInt(starts) : undefined;
|
||||
let endTime = ends ? parseInt(ends) : undefined;
|
||||
|
||||
let status = stream.tags.find((t) => t[0] === "status")?.[1] || "ended";
|
||||
if (status === "ended" && endTime === undefined) endTime = stream.created_at;
|
||||
if (endTime && endTime > dayjs().unix()) {
|
||||
status = "ended";
|
||||
}
|
||||
@ -59,12 +64,11 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
|
||||
status = "ended";
|
||||
}
|
||||
|
||||
const host = stream.tags.filter(isPTag)[0]?.[1] ?? stream.pubkey;
|
||||
const tags = unique(stream.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1] as string));
|
||||
|
||||
return {
|
||||
author: stream.pubkey,
|
||||
host,
|
||||
host: getStreamHost(stream),
|
||||
event: stream,
|
||||
updated: stream.created_at,
|
||||
streaming,
|
||||
|
@ -2,7 +2,7 @@ export const getMatchNostrLink = () =>
|
||||
/(nostr:|@)?((npub|note|nprofile|nevent|nrelay|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi;
|
||||
export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}]+)/gu;
|
||||
export const getMatchLink = () =>
|
||||
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu;
|
||||
/https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:!]*)/gu;
|
||||
export const getMatchEmoji = () => /:([a-zA-Z0-9_]+):/gi;
|
||||
|
||||
// read more https://www.regular-expressions.info/unicode.html#category
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
|
||||
import appSettings, { replaceSettings } from "../services/settings/app-settings";
|
||||
import useSubject from "./use-subject";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { AppSettings } from "../services/settings/migrations";
|
||||
|
||||
export default function useAppSettings() {
|
||||
|
@ -3,7 +3,7 @@ import { DependencyList, useCallback } from "react";
|
||||
|
||||
export default function useAsyncErrorHandler<T = any>(
|
||||
fn: () => Promise<T>,
|
||||
deps: DependencyList = [],
|
||||
deps: DependencyList,
|
||||
): () => Promise<T | undefined> {
|
||||
const toast = useToast();
|
||||
|
||||
|
@ -10,9 +10,8 @@ export function useClientRelays(mode: RelayMode = RelayMode.ALL) {
|
||||
}
|
||||
export function useReadRelayUrls(additional: string[] = []) {
|
||||
const relays = useClientRelays(RelayMode.READ);
|
||||
|
||||
const urls = relays.map((r) => r.url);
|
||||
if (additional) {
|
||||
if (additional.length > 0) {
|
||||
return unique([...urls, ...additional]);
|
||||
}
|
||||
return urls;
|
||||
|
20
src/hooks/use-client-side-mute-filter.ts
Normal file
20
src/hooks/use-client-side-mute-filter.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useCurrentAccount } from "./use-current-account";
|
||||
import useWordMuteFilter from "./use-mute-word-filter";
|
||||
import useUserMuteFilter from "./use-user-mute-filter";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
|
||||
export default function useClientSideMuteFilter() {
|
||||
const account = useCurrentAccount();
|
||||
|
||||
const wordMuteFilter = useWordMuteFilter();
|
||||
const mustListFilter = useUserMuteFilter(account?.pubkey);
|
||||
|
||||
return useCallback(
|
||||
(event: NostrEvent) => {
|
||||
return wordMuteFilter(event) || mustListFilter(event);
|
||||
},
|
||||
[wordMuteFilter, mustListFilter],
|
||||
);
|
||||
}
|
@ -3,12 +3,12 @@ import eventReactionsService from "../services/event-reactions";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import useSubject from "./use-subject";
|
||||
|
||||
export default function useEventReactions(eventId: string, additionalRelays: string[] = [], alwaysFetch = true) {
|
||||
export default function useEventReactions(eventId: string, additionalRelays: string[] = [], alwaysRequest = true) {
|
||||
const relays = useReadRelayUrls(additionalRelays);
|
||||
|
||||
const subject = useMemo(
|
||||
() => eventReactionsService.requestReactions(eventId, relays, alwaysFetch),
|
||||
[eventId, relays.join("|"), alwaysFetch],
|
||||
() => eventReactionsService.requestReactions(eventId, relays, alwaysRequest),
|
||||
[eventId, relays.join("|"), alwaysRequest],
|
||||
);
|
||||
|
||||
return useSubject(subject);
|
||||
|
@ -5,12 +5,12 @@ import { useReadRelayUrls } from "./use-client-relays";
|
||||
import useSubject from "./use-subject";
|
||||
import { parseZapEvent } from "../helpers/nostr/zaps";
|
||||
|
||||
export default function useEventZaps(eventUID: string, additionalRelays: string[] = [], alwaysFetch = true) {
|
||||
export default function useEventZaps(eventUID: string, additionalRelays: string[] = [], alwaysRequest = true) {
|
||||
const relays = useReadRelayUrls(additionalRelays);
|
||||
|
||||
const subject = useMemo(
|
||||
() => eventZapsService.requestZaps(eventUID, relays, alwaysFetch),
|
||||
[eventUID, relays.join("|"), alwaysFetch],
|
||||
() => eventZapsService.requestZaps(eventUID, relays, alwaysRequest),
|
||||
[eventUID, relays.join("|"), alwaysRequest],
|
||||
);
|
||||
|
||||
const events = useSubject(subject) || [];
|
||||
|
@ -1,16 +1,21 @@
|
||||
import useReplaceableEvent from "./use-replaceable-event";
|
||||
import { useCurrentAccount } from "./use-current-account";
|
||||
import { USER_EMOJI_LIST_KIND } from "../helpers/nostr/emoji-packs";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
|
||||
export const FAVORITE_LISTS_IDENTIFIER = "nostrudel-favorite-lists";
|
||||
|
||||
export default function useFavoriteEmojiPacks(pubkey?: string, additionalRelays: string[] = [], alwaysFetch = false) {
|
||||
export default function useFavoriteEmojiPacks(
|
||||
pubkey?: string,
|
||||
additionalRelays: string[] = [],
|
||||
opts: RequestOptions = {},
|
||||
) {
|
||||
const account = useCurrentAccount();
|
||||
const key = pubkey || account?.pubkey;
|
||||
const favoritePacks = useReplaceableEvent(
|
||||
key ? { kind: USER_EMOJI_LIST_KIND, pubkey: key } : undefined,
|
||||
additionalRelays,
|
||||
alwaysFetch,
|
||||
opts,
|
||||
);
|
||||
|
||||
return favoritePacks;
|
||||
|
@ -9,6 +9,8 @@ export default function useFavoriteLists() {
|
||||
const account = useCurrentAccount();
|
||||
const favoriteList = useReplaceableEvent(
|
||||
account ? { kind: 30078, pubkey: account.pubkey, identifier: FAVORITE_LISTS_IDENTIFIER } : undefined,
|
||||
[],
|
||||
{ ignoreCache: true },
|
||||
);
|
||||
|
||||
const lists = useReplaceableEvents(favoriteList ? getCoordinatesFromList(favoriteList).map((a) => a.coordinate) : []);
|
||||
|
25
src/hooks/use-mute-word-filter.ts
Normal file
25
src/hooks/use-mute-word-filter.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import useAppSettings from "./use-app-settings";
|
||||
|
||||
export default function useWordMuteFilter() {
|
||||
const { mutedWords } = useAppSettings();
|
||||
|
||||
const regexp = useMemo(() => {
|
||||
if (!mutedWords) return;
|
||||
const words = mutedWords
|
||||
.split(/[,\n]/g)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return new RegExp(`(?:^|\\s|#)(?:${words.join("|")})(?:\\s|$)`, "i");
|
||||
}, [mutedWords]);
|
||||
|
||||
return useCallback(
|
||||
(event: NostrEvent) => {
|
||||
if (!regexp) return false;
|
||||
return event.content.match(regexp) !== null;
|
||||
},
|
||||
[mutedWords],
|
||||
);
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import { useMemo } from "react";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import replaceableEventLoaderService, { RequestOptions } from "../services/replaceable-event-requester";
|
||||
import { CustomEventPointer, parseCoordinate } from "../helpers/nostr/events";
|
||||
import useSubject from "./use-subject";
|
||||
|
||||
export default function useReplaceableEvent(
|
||||
cord: string | CustomEventPointer | undefined,
|
||||
additionalRelays: string[] = [],
|
||||
alwaysRequest = false,
|
||||
opts: RequestOptions = {},
|
||||
) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
const sub = useMemo(() => {
|
||||
@ -18,9 +18,9 @@ export default function useReplaceableEvent(
|
||||
parsed.kind,
|
||||
parsed.pubkey,
|
||||
parsed.identifier,
|
||||
alwaysRequest,
|
||||
opts,
|
||||
);
|
||||
}, [cord, readRelays.join("|")]);
|
||||
}, [cord, readRelays.join("|"), opts?.alwaysRequest, opts?.ignoreCache]);
|
||||
|
||||
return useSubject(sub);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import replaceableEventLoaderService, { RequestOptions } from "../services/replaceable-event-requester";
|
||||
import { CustomEventPointer, parseCoordinate } from "../helpers/nostr/events";
|
||||
import Subject from "../classes/subject";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
@ -10,7 +10,7 @@ import useSubjects from "./use-subjects";
|
||||
export default function useReplaceableEvents(
|
||||
coordinates: string[] | CustomEventPointer[] | undefined,
|
||||
additionalRelays: string[] = [],
|
||||
alwaysRequest = false,
|
||||
opts: RequestOptions = {},
|
||||
) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
const subs = useMemo(() => {
|
||||
@ -25,7 +25,7 @@ export default function useReplaceableEvents(
|
||||
parsed.kind,
|
||||
parsed.pubkey,
|
||||
parsed.identifier,
|
||||
alwaysRequest,
|
||||
opts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,16 +1,13 @@
|
||||
import { useAsync } from "react-use";
|
||||
|
||||
import singleEventService from "../services/single-event";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import { useMemo } from "react";
|
||||
import useSubject from "./use-subject";
|
||||
|
||||
export default function useSingleEvent(id?: string, additionalRelays: string[] = []) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
const { loading, value: event } = useAsync(async () => {
|
||||
const subject = useMemo(() => {
|
||||
if (id) return singleEventService.requestEvent(id, readRelays);
|
||||
}, [id, readRelays.join("|")]);
|
||||
|
||||
return {
|
||||
event,
|
||||
loading,
|
||||
};
|
||||
return useSubject(subject);
|
||||
}
|
||||
|
@ -4,19 +4,17 @@ import { GOAL_KIND } from "../helpers/nostr/goal";
|
||||
import { ParsedStream, getATag } from "../helpers/nostr/stream";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import singleEventService from "../services/single-event";
|
||||
import { NostrRequest } from "../classes/nostr-request";
|
||||
import useSingleEvent from "./use-single-event";
|
||||
|
||||
export default function useStreamGoal(stream: ParsedStream) {
|
||||
const [goal, setGoal] = useState<NostrEvent>();
|
||||
const relays = useReadRelayUrls(stream.relays);
|
||||
|
||||
const streamGoal = useSingleEvent(stream.goal);
|
||||
|
||||
useEffect(() => {
|
||||
if (stream.goal) {
|
||||
singleEventService.requestEvent(stream.goal, relays).then((event) => {
|
||||
setGoal(event);
|
||||
});
|
||||
} else {
|
||||
if (!stream.goal) {
|
||||
const request = new NostrRequest(relays);
|
||||
request.onEvent.subscribe((event) => {
|
||||
setGoal(event);
|
||||
@ -25,5 +23,5 @@ export default function useStreamGoal(stream: ParsedStream) {
|
||||
}
|
||||
}, [stream.identifier, stream.goal, relays.join("|")]);
|
||||
|
||||
return goal;
|
||||
return streamGoal || goal;
|
||||
}
|
||||
|
26
src/hooks/use-subscribed-communities-list.ts
Normal file
26
src/hooks/use-subscribed-communities-list.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { COMMUNITY_DEFINITION_KIND, SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER } from "../helpers/nostr/communities";
|
||||
import { NOTE_LIST_KIND, getParsedCordsFromList } from "../helpers/nostr/lists";
|
||||
import { useCurrentAccount } from "./use-current-account";
|
||||
import useReplaceableEvent from "./use-replaceable-event";
|
||||
|
||||
export default function useSubscribedCommunitiesList(pubkey?: string) {
|
||||
const account = useCurrentAccount();
|
||||
const key = pubkey ?? account?.pubkey;
|
||||
|
||||
const list = useReplaceableEvent(
|
||||
key
|
||||
? {
|
||||
kind: NOTE_LIST_KIND,
|
||||
identifier: SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER,
|
||||
pubkey: key,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const pointers = list ? getParsedCordsFromList(list).filter((cord) => cord.kind === COMMUNITY_DEFINITION_KIND) : [];
|
||||
|
||||
return {
|
||||
list,
|
||||
pointers,
|
||||
};
|
||||
}
|
@ -1,6 +1,11 @@
|
||||
import { Kind } from "nostr-tools";
|
||||
import useReplaceableEvent from "./use-replaceable-event";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
|
||||
export default function useUserContactList(pubkey?: string, additionalRelays: string[] = [], alwaysRequest = true) {
|
||||
return useReplaceableEvent(pubkey && { kind: Kind.Contacts, pubkey }, additionalRelays, alwaysRequest);
|
||||
export default function useUserContactList(
|
||||
pubkey?: string,
|
||||
additionalRelays: string[] = [],
|
||||
opts: RequestOptions = {},
|
||||
) {
|
||||
return useReplaceableEvent(pubkey && { kind: Kind.Contacts, pubkey }, additionalRelays, opts);
|
||||
}
|
||||
|
@ -1,10 +1,16 @@
|
||||
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../helpers/nostr/lists";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND, isJunkList } from "../helpers/nostr/lists";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import useSubject from "./use-subject";
|
||||
import useTimelineLoader from "./use-timeline-loader";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
|
||||
export default function useUserLists(pubkey?: string, additionalRelays: string[] = []) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
const eventFilter = useCallback((event: NostrEvent) => {
|
||||
return !isJunkList(event);
|
||||
}, []);
|
||||
const timeline = useTimelineLoader(
|
||||
`${pubkey}-lists`,
|
||||
readRelays,
|
||||
@ -12,7 +18,7 @@ export default function useUserLists(pubkey?: string, additionalRelays: string[]
|
||||
authors: pubkey ? [pubkey] : [],
|
||||
kinds: [PEOPLE_LIST_KIND, NOTE_LIST_KIND],
|
||||
},
|
||||
{ enabled: !!pubkey },
|
||||
{ enabled: !!pubkey, eventFilter },
|
||||
);
|
||||
|
||||
return useSubject(timeline.timeline);
|
||||
|
@ -2,14 +2,12 @@ import { useMemo } from "react";
|
||||
import userMetadataService from "../services/user-metadata";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import useSubject from "./use-subject";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
|
||||
export function useUserMetadata(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
|
||||
const relays = useReadRelayUrls(additionalRelays);
|
||||
export function useUserMetadata(pubkey: string, additionalRelays: string[] = [], opts: RequestOptions = {}) {
|
||||
const relays = useReadRelayUrls([...additionalRelays, "wss://purplepag.es"]);
|
||||
|
||||
const subject = useMemo(
|
||||
() => userMetadataService.requestMetadata(pubkey, relays, alwaysRequest),
|
||||
[pubkey, relays, alwaysRequest],
|
||||
);
|
||||
const subject = useMemo(() => userMetadataService.requestMetadata(pubkey, relays, opts), [pubkey, relays]);
|
||||
const metadata = useSubject(subject);
|
||||
|
||||
return metadata;
|
||||
|
23
src/hooks/use-user-mute-filter.ts
Normal file
23
src/hooks/use-user-mute-filter.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { useCurrentAccount } from "./use-current-account";
|
||||
import useUserMuteList from "./use-user-mute-list";
|
||||
import { getPubkeysFromList } from "../helpers/nostr/lists";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { STREAM_KIND, getStreamHost } from "../helpers/nostr/stream";
|
||||
|
||||
export default function useUserMuteFilter(pubkey?: string) {
|
||||
const account = useCurrentAccount();
|
||||
const muteList = useUserMuteList(pubkey || account?.pubkey, [], { ignoreCache: true });
|
||||
const pubkeys = useMemo(() => (muteList ? getPubkeysFromList(muteList).map((p) => p.pubkey) : []), [muteList]);
|
||||
|
||||
return useCallback(
|
||||
(event: NostrEvent) => {
|
||||
if (event.kind === STREAM_KIND) {
|
||||
if (pubkeys.includes(getStreamHost(event))) return true;
|
||||
}
|
||||
return pubkeys.includes(event.pubkey);
|
||||
},
|
||||
[pubkeys],
|
||||
);
|
||||
}
|
31
src/hooks/use-user-mute-functions.ts
Normal file
31
src/hooks/use-user-mute-functions.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
import { createEmptyMuteList, listAddPerson, listRemovePerson, isPubkeyInList } from "../helpers/nostr/lists";
|
||||
import { useSigningContext } from "../providers/signing-provider";
|
||||
import clientRelaysService from "../services/client-relays";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import useAsyncErrorHandler from "./use-async-error-handler";
|
||||
import { useCurrentAccount } from "./use-current-account";
|
||||
import useUserMuteList from "./use-user-mute-list";
|
||||
|
||||
export default function useUserMuteFunctions(pubkey: string) {
|
||||
const account = useCurrentAccount()!;
|
||||
const { requestSignature } = useSigningContext();
|
||||
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
|
||||
|
||||
const isMuted = isPubkeyInList(muteList, pubkey);
|
||||
|
||||
const mute = useAsyncErrorHandler(async () => {
|
||||
const draft = listAddPerson(muteList || createEmptyMuteList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
new NostrPublishAction("Mute", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
}, [requestSignature, muteList]);
|
||||
const unmute = useAsyncErrorHandler(async () => {
|
||||
const draft = listRemovePerson(muteList || createEmptyMuteList(), pubkey);
|
||||
const signed = await requestSignature(draft);
|
||||
new NostrPublishAction("Unmute", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
}, [requestSignature, muteList]);
|
||||
|
||||
return { isMuted, mute, unmute };
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import useReplaceableEvent from "./use-replaceable-event";
|
||||
import { MUTE_LIST_KIND } from "../helpers/nostr/lists";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
|
||||
export default function useUserMuteList(pubkey?: string, additionalRelays: string[] = [], alwaysRequest = true) {
|
||||
return useReplaceableEvent(pubkey && { kind: MUTE_LIST_KIND, pubkey }, additionalRelays, alwaysRequest);
|
||||
export default function useUserMuteList(pubkey?: string, additionalRelays: string[] = [], opts: RequestOptions = {}) {
|
||||
return useReplaceableEvent(pubkey && { kind: MUTE_LIST_KIND, pubkey }, additionalRelays, opts);
|
||||
}
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import useReplaceableEvent from "./use-replaceable-event";
|
||||
import { PEOPLE_LIST_KIND, getPubkeysFromList } from "../helpers/nostr/lists";
|
||||
import useUserMuteList from "./use-user-mute-list";
|
||||
import { useMemo } from "react";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
|
||||
export default function useUserMuteLists(pubkey?: string, additionalRelays: string[] = [], alwaysRequest = true) {
|
||||
const muteList = useUserMuteList(pubkey, additionalRelays, alwaysRequest);
|
||||
export default function useUserMuteLists(pubkey?: string, additionalRelays: string[] = [], opts: RequestOptions = {}) {
|
||||
const muteList = useUserMuteList(pubkey, additionalRelays, opts);
|
||||
const altMuteList = useReplaceableEvent(
|
||||
pubkey && { kind: PEOPLE_LIST_KIND, pubkey, identifier: "mute" },
|
||||
additionalRelays,
|
||||
alwaysRequest,
|
||||
opts,
|
||||
);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
|
@ -2,12 +2,13 @@ import { useMemo } from "react";
|
||||
import userRelaysService from "../services/user-relays";
|
||||
import useSubject from "./use-subject";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
|
||||
export function useUserRelays(pubkey: string, additionalRelays: string[] = [], alwaysRequest = false) {
|
||||
const relays = useReadRelayUrls(additionalRelays);
|
||||
export function useUserRelays(pubkey: string, additionalRelays: string[] = [], opts: RequestOptions = {}) {
|
||||
const readRelays = useReadRelayUrls([...additionalRelays, "wss://purplepag.es"]);
|
||||
const subject = useMemo(
|
||||
() => userRelaysService.requestRelays(pubkey, relays, alwaysRequest),
|
||||
[pubkey, relays.join("|"), alwaysRequest],
|
||||
() => userRelaysService.requestRelays(pubkey, readRelays, opts),
|
||||
[pubkey, readRelays.join("|")],
|
||||
);
|
||||
const userRelays = useSubject(subject);
|
||||
|
||||
|
130
src/providers/drawer-sub-view-provider.tsx
Normal file
130
src/providers/drawer-sub-view-provider.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import {
|
||||
ButtonGroup,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerProps,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { RouteObject, RouterProvider, To, createMemoryRouter, useNavigate } from "react-router-dom";
|
||||
|
||||
import { ErrorBoundary } from "../components/error-boundary";
|
||||
import NoteView from "../views/note";
|
||||
import { ArrowLeftSIcon, ArrowRightSIcon, ExternalLinkIcon } from "../components/icons";
|
||||
import { PageProviders } from ".";
|
||||
|
||||
type Router = ReturnType<typeof createMemoryRouter>;
|
||||
|
||||
const IsInDrawerContext = createContext(false);
|
||||
const DrawerSubViewContext = createContext<{ openDrawer: (route: To) => void; closeDrawer: () => void }>({
|
||||
openDrawer() {},
|
||||
closeDrawer() {},
|
||||
});
|
||||
|
||||
function DrawerSubView({
|
||||
router,
|
||||
openInParent,
|
||||
...props
|
||||
}: Omit<DrawerProps, "children"> & { router: Router; openInParent: (to: To) => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
return (
|
||||
<Drawer size="xl" {...props}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader p="2">
|
||||
<ButtonGroup size="sm">
|
||||
<IconButton icon={<ArrowLeftSIcon />} aria-label="Back" onClick={() => router.navigate(-1)} />
|
||||
<IconButton icon={<ArrowRightSIcon />} aria-label="Forward" onClick={() => router.navigate(+1)} />
|
||||
<IconButton
|
||||
icon={<ExternalLinkIcon />}
|
||||
aria-label="Open"
|
||||
onClick={() => openInParent(router.state.location)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
{title}
|
||||
</DrawerHeader>
|
||||
<DrawerBody px="2" pb="2" pt="0">
|
||||
<ErrorBoundary>
|
||||
<IsInDrawerContext.Provider value={true}>
|
||||
<PageProviders>
|
||||
<RouterProvider router={router} />
|
||||
</PageProviders>
|
||||
</IsInDrawerContext.Provider>
|
||||
</ErrorBoundary>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
path: "/n/:id",
|
||||
element: <NoteView />,
|
||||
},
|
||||
];
|
||||
|
||||
export function useDrawerSubView() {
|
||||
return useContext(DrawerSubViewContext);
|
||||
}
|
||||
|
||||
export function useNavigateInDrawer() {
|
||||
const navigate = useNavigate();
|
||||
const isInDrawer = useContext(IsInDrawerContext);
|
||||
const { openDrawer } = useDrawerSubView();
|
||||
|
||||
return isInDrawer ? navigate : openDrawer;
|
||||
}
|
||||
|
||||
export default function DrawerSubViewProvider({
|
||||
children,
|
||||
parentRouter,
|
||||
}: PropsWithChildren & { parentRouter: Router }) {
|
||||
const [router, setRouter] = useState<Router | null>(null);
|
||||
|
||||
const openInParent = useCallback(
|
||||
(to: To) => {
|
||||
parentRouter.navigate(to);
|
||||
setRouter(null);
|
||||
},
|
||||
[parentRouter],
|
||||
);
|
||||
|
||||
const openDrawer = useCallback(
|
||||
(to: To) => {
|
||||
const newRouter = createMemoryRouter(routes, { initialEntries: [to] });
|
||||
newRouter.subscribe((e) => {
|
||||
if (!!e.errors?.["__shim-error-route__"]) {
|
||||
openInParent(e.location);
|
||||
}
|
||||
});
|
||||
setRouter(newRouter);
|
||||
},
|
||||
[setRouter, openInParent],
|
||||
);
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
setRouter(null);
|
||||
}, [setRouter]);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
}),
|
||||
[openDrawer, closeDrawer],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerSubViewContext.Provider value={context}>
|
||||
{children}
|
||||
{router && <DrawerSubView router={router} isOpen onClose={closeDrawer} openInParent={openInParent} />}
|
||||
</DrawerSubViewContext.Provider>
|
||||
);
|
||||
}
|
@ -26,7 +26,10 @@ export function DefaultEmojiProvider({ children }: PropsWithChildren) {
|
||||
|
||||
export function UserEmojiProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) {
|
||||
const account = useCurrentAccount();
|
||||
const favoritePacks = useFavoriteEmojiPacks(pubkey || account?.pubkey, [], true);
|
||||
const favoritePacks = useFavoriteEmojiPacks(pubkey || account?.pubkey, [], {
|
||||
ignoreCache: true,
|
||||
alwaysRequest: true,
|
||||
});
|
||||
const events = useReplaceableEvents(favoritePacks && getPackCordsFromFavorites(favoritePacks));
|
||||
|
||||
const emojis = events
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
|
||||
|
||||
import { SigningProvider } from "./signing-provider";
|
||||
import createTheme from "../theme";
|
||||
import useAppSettings from "../hooks/use-app-settings";
|
||||
@ -12,8 +13,11 @@ import { UserContactsUserDirectoryProvider } from "./user-directory-provider";
|
||||
|
||||
// Top level providers, should be render as close to the root as possible
|
||||
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
const { primaryColor } = useAppSettings();
|
||||
const theme = useMemo(() => createTheme(primaryColor), [primaryColor]);
|
||||
const { primaryColor, maxPageWidth } = useAppSettings();
|
||||
const theme = useMemo(
|
||||
() => createTheme(primaryColor, maxPageWidth !== "none" ? maxPageWidth : undefined),
|
||||
[primaryColor, maxPageWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme} colorModeManager={localStorageManager}>
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { PropsWithChildren, createContext, useContext, useEffect, useMemo } from "react";
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import { useReadRelayUrls } from "../hooks/use-client-relays";
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import { TimelineLoader } from "../classes/timeline-loader";
|
||||
import timelineCacheService from "../services/timeline-cache";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import useClientSideMuteFilter from "../hooks/use-client-side-mute-filter";
|
||||
|
||||
type NotificationTimelineContextType = {
|
||||
timeline?: TimelineLoader;
|
||||
@ -29,6 +31,18 @@ export default function NotificationTimelineProvider({ children }: PropsWithChil
|
||||
: undefined;
|
||||
}, [account?.pubkey]);
|
||||
|
||||
const userMuteFilter = useClientSideMuteFilter();
|
||||
const eventFilter = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
if (userMuteFilter(event)) return false;
|
||||
return true;
|
||||
},
|
||||
[userMuteFilter],
|
||||
);
|
||||
useEffect(() => {
|
||||
timeline?.setFilter(eventFilter);
|
||||
}, [timeline, eventFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeline && account?.pubkey) {
|
||||
timeline.setQuery([{ "#p": [account?.pubkey], kinds: [Kind.Text, Kind.Repost, Kind.Reaction, Kind.Zap] }]);
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
|
||||
import { Kind } from "nostr-tools";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import { getPubkeysFromList } from "../helpers/nostr/lists";
|
||||
import useReplaceableEvent from "../hooks/use-replaceable-event";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { NostrQuery } from "../types/nostr-query";
|
||||
import { searchParamsToJson } from "../helpers/url";
|
||||
|
||||
export type ListId = "following" | "global" | string;
|
||||
export type Person = { pubkey: string; relay?: string };
|
||||
@ -46,17 +45,21 @@ export type PeopleListProviderProps = PropsWithChildren & {
|
||||
};
|
||||
export default function PeopleListProvider({ children, initList = "following" }: PeopleListProviderProps) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const selected = params.get("people") || (initList as ListId);
|
||||
const setSelected = useCallback(
|
||||
(value: ListId) => {
|
||||
setParams((p) => ({ ...searchParamsToJson(p), people: value }));
|
||||
const newParams = new URLSearchParams(location.search);
|
||||
newParams.set("people", value);
|
||||
navigate(location.pathname + "?" + newParams.toString(), { state: location.state });
|
||||
},
|
||||
[setParams],
|
||||
[navigate, location],
|
||||
);
|
||||
|
||||
const listId = useListCoordinate(selected);
|
||||
const listEvent = useReplaceableEvent(listId, [], true);
|
||||
const listEvent = useReplaceableEvent(listId, [], { alwaysRequest: true });
|
||||
|
||||
const people = listEvent && getPubkeysFromList(listEvent);
|
||||
|
||||
|
@ -31,22 +31,22 @@ export default function RelaySelectionProvider({
|
||||
overrideDefault,
|
||||
additionalDefaults,
|
||||
}: RelaySelectionProviderProps) {
|
||||
const { state } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const userReadRelays = useReadRelayUrls();
|
||||
const relays = useMemo(() => {
|
||||
if (state?.relays) return state.relays;
|
||||
if (location.state?.relays) return location.state.relays;
|
||||
if (overrideDefault) return overrideDefault;
|
||||
if (additionalDefaults) return unique([...userReadRelays, ...additionalDefaults]);
|
||||
return userReadRelays;
|
||||
}, [state?.relays, overrideDefault, userReadRelays.join("|"), additionalDefaults]);
|
||||
}, [location.state?.relays, overrideDefault, userReadRelays.join("|"), additionalDefaults]);
|
||||
|
||||
const setSelected = useCallback(
|
||||
(relays: string[]) => {
|
||||
navigate(".", { state: { relays }, replace: true });
|
||||
navigate(location.pathname + location.search, { state: { relays }, replace: true });
|
||||
},
|
||||
[navigate],
|
||||
[navigate, location],
|
||||
);
|
||||
|
||||
const context = useMemo(
|
||||
|
8
src/routes.tsx
Normal file
8
src/routes.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { RouteObject } from "react-router-dom";
|
||||
|
||||
import NoteView from "./views/note";
|
||||
|
||||
export const threadRoute: RouteObject = {
|
||||
path: "/n/:id",
|
||||
element: <NoteView />,
|
||||
};
|
@ -57,11 +57,13 @@ class ClientRelayService {
|
||||
|
||||
// load the relays from cache or bootstrap relays
|
||||
this.log("Load users relays from cache or bootstrap relays");
|
||||
lastSubject = userRelaysService.requestRelays(account.pubkey, Array.from(this.bootstrapRelays));
|
||||
lastSubject = userRelaysService.requestRelays(account.pubkey, Array.from(this.bootstrapRelays), {
|
||||
alwaysRequest: true,
|
||||
});
|
||||
setTimeout(() => {
|
||||
// double check for new relay notes
|
||||
this.log("Requesting latest relays from the write relays");
|
||||
userRelaysService.requestRelays(account.pubkey, this.getWriteUrls(), true);
|
||||
userRelaysService.requestRelays(account.pubkey, this.getWriteUrls(), { alwaysRequest: true });
|
||||
}, 1000);
|
||||
|
||||
this.relays.connectWithHandler(lastSubject, userRelaysToRelayConfig);
|
||||
|
@ -12,10 +12,10 @@ class EventReactionsService {
|
||||
subjects = new SuperMap<eventId, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
|
||||
pending = new SuperMap<eventId, Set<relay>>(() => new Set());
|
||||
|
||||
requestReactions(eventId: string, relays: relay[], alwaysFetch = true) {
|
||||
requestReactions(eventId: string, relays: relay[], alwaysRequest = true) {
|
||||
const subject = this.subjects.get(eventId);
|
||||
|
||||
if (!subject.value || alwaysFetch) {
|
||||
if (!subject.value || alwaysRequest) {
|
||||
for (const relay of relays) {
|
||||
this.pending.get(eventId).add(relay);
|
||||
}
|
||||
|
@ -15,10 +15,10 @@ class EventZapsService {
|
||||
subjects = new SuperMap<eventUID, Subject<NostrEvent[]>>(() => new Subject<NostrEvent[]>([]));
|
||||
pending = new SuperMap<eventUID, Set<relay>>(() => new Set());
|
||||
|
||||
requestZaps(eventUID: eventUID, relays: relay[], alwaysFetch = true) {
|
||||
requestZaps(eventUID: eventUID, relays: relay[], alwaysRequest = true) {
|
||||
const subject = this.subjects.get(eventUID);
|
||||
|
||||
if (!subject.value || alwaysFetch) {
|
||||
if (!subject.value || alwaysRequest) {
|
||||
for (const relay of relays) {
|
||||
this.pending.get(eventUID).add(relay);
|
||||
}
|
||||
|
@ -16,6 +16,15 @@ import createDefer, { Deferred } from "../classes/deferred";
|
||||
type Pubkey = string;
|
||||
type Relay = string;
|
||||
|
||||
export type RequestOptions = {
|
||||
/** Always request the event from the relays */
|
||||
alwaysRequest?: boolean;
|
||||
/** ignore the cache on initial load */
|
||||
ignoreCache?: boolean;
|
||||
// TODO: figure out a clean way for useReplaceableEvent hook to "unset" or "unsubscribe"
|
||||
// keepAlive?: boolean;
|
||||
};
|
||||
|
||||
export function getHumanReadableCoordinate(kind: number, pubkey: string, d?: string) {
|
||||
return `${kind}:${nameOrPubkey(pubkey)}${d ? ":" + d : ""}`;
|
||||
}
|
||||
@ -249,17 +258,17 @@ class ReplaceableEventLoaderService {
|
||||
return sub;
|
||||
}
|
||||
|
||||
requestEvent(relays: string[], kind: number, pubkey: string, d?: string, alwaysRequest = false) {
|
||||
requestEvent(relays: string[], kind: number, pubkey: string, d?: string, opts: RequestOptions = {}) {
|
||||
const cord = createCoordinate(kind, pubkey, d);
|
||||
const sub = this.events.get(cord);
|
||||
|
||||
if (!sub.value) {
|
||||
this.loadFromCache(cord).then((loaded) => {
|
||||
if (!loaded) this.requestEventFromRelays(relays, kind, pubkey, d);
|
||||
if (!loaded && !sub.value) this.requestEventFromRelays(relays, kind, pubkey, d);
|
||||
});
|
||||
}
|
||||
|
||||
if (alwaysRequest) {
|
||||
if (opts?.alwaysRequest || (!sub.value && opts.ignoreCache)) {
|
||||
this.requestEventFromRelays(relays, kind, pubkey, d);
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,6 @@ export async function replaceSettings(newSettings: AppSettings) {
|
||||
const signed = await signingService.requestSignature(draft, account);
|
||||
userAppSettings.receiveEvent(signed);
|
||||
const pub = new NostrPublishAction("Update Settings", clientRelaysService.getWriteUrls(), signed);
|
||||
await pub.onComplete;
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,7 +44,9 @@ accountService.current.subscribe(() => {
|
||||
log("Loaded user settings from local storage");
|
||||
}
|
||||
|
||||
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), true);
|
||||
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), {
|
||||
alwaysRequest: true,
|
||||
});
|
||||
appSettings.next(defaultSettings);
|
||||
appSettings.connect(subject);
|
||||
});
|
||||
@ -55,7 +56,7 @@ clientRelaysService.relays.subscribe(() => {
|
||||
const account = accountService.current.value;
|
||||
|
||||
if (account) {
|
||||
userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), true);
|
||||
userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), { alwaysRequest: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -22,15 +22,24 @@ export type AppSettingsV0 = {
|
||||
redditRedirect?: string;
|
||||
youtubeRedirect?: string;
|
||||
};
|
||||
export type AppSettingsV1 = Omit<AppSettingsV0, "version"> & {
|
||||
version: 1;
|
||||
mutedWords?: string;
|
||||
maxPageWidth: "none" | "md" | "lg" | "xl";
|
||||
};
|
||||
export function isV0(settings: { version: number }): settings is AppSettingsV0 {
|
||||
return settings.version === undefined || settings.version === 0;
|
||||
}
|
||||
export function isV1(settings: { version: number }): settings is AppSettingsV1 {
|
||||
return settings.version === 1;
|
||||
}
|
||||
|
||||
export type AppSettings = AppSettingsV0;
|
||||
export type AppSettings = AppSettingsV1;
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
version: 0,
|
||||
version: 1,
|
||||
colorMode: "system",
|
||||
maxPageWidth: "none",
|
||||
blurImages: true,
|
||||
autoShowMedia: true,
|
||||
proxyUserMedia: false,
|
||||
@ -49,8 +58,9 @@ export const defaultSettings: AppSettings = {
|
||||
youtubeRedirect: undefined,
|
||||
};
|
||||
|
||||
export function upgradeSettings(settings: { version: number }) {
|
||||
if (isV0(settings)) return settings;
|
||||
export function upgradeSettings(settings: { version: number }): AppSettings | null {
|
||||
if (isV0(settings)) return { ...settings, version: 1, maxPageWidth: "none" };
|
||||
if (isV1(settings)) return settings;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import { SuperMap } from "../../classes/super-map";
|
||||
import { PersistentSubject } from "../../classes/subject";
|
||||
import { AppSettings, defaultSettings, parseAppSettings } from "./migrations";
|
||||
import replaceableEventLoaderService from "../replaceable-event-requester";
|
||||
import replaceableEventLoaderService, { RequestOptions } from "../replaceable-event-requester";
|
||||
|
||||
const SETTING_EVENT_IDENTIFIER = "nostrudel-settings";
|
||||
|
||||
@ -16,14 +16,14 @@ class UserAppSettings {
|
||||
getSubject(pubkey: string) {
|
||||
return this.parsedSubjects.get(pubkey);
|
||||
}
|
||||
requestAppSettings(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
requestAppSettings(pubkey: string, relays: string[], opts?: RequestOptions) {
|
||||
const sub = this.parsedSubjects.get(pubkey);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(
|
||||
relays,
|
||||
30078,
|
||||
pubkey,
|
||||
SETTING_EVENT_IDENTIFIER,
|
||||
alwaysRequest,
|
||||
opts,
|
||||
);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseAppSettings(event)));
|
||||
return sub;
|
||||
|
@ -1,31 +1,30 @@
|
||||
import createDefer, { Deferred } from "../classes/deferred";
|
||||
import _throttle from "lodash.throttle";
|
||||
|
||||
import { NostrRequest } from "../classes/nostr-request";
|
||||
import Subject from "../classes/subject";
|
||||
import { SuperMap } from "../classes/super-map";
|
||||
import { safeRelayUrls } from "../helpers/url";
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
|
||||
class SingleEventService {
|
||||
eventCache = new Map<string, NostrEvent>();
|
||||
private cache = new SuperMap<string, Subject<NostrEvent>>(() => new Subject());
|
||||
pending = new Map<string, string[]>();
|
||||
pendingPromises = new Map<string, Deferred<NostrEvent>>();
|
||||
|
||||
async requestEvent(id: string, relays: string[]) {
|
||||
const event = this.eventCache.get(id);
|
||||
if (event) return event;
|
||||
requestEvent(id: string, relays: string[]) {
|
||||
const subject = this.cache.get(id);
|
||||
if (subject.value) return subject;
|
||||
|
||||
this.pending.set(id, this.pending.get(id)?.concat(safeRelayUrls(relays)) ?? safeRelayUrls(relays));
|
||||
const deferred = createDefer<NostrEvent>();
|
||||
this.pendingPromises.set(id, deferred);
|
||||
return deferred;
|
||||
this.batchRequestsThrottle();
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
handleEvent(event: NostrEvent) {
|
||||
this.eventCache.set(event.id, event);
|
||||
if (this.pendingPromises.has(event.id)) {
|
||||
this.pendingPromises.get(event.id)?.resolve(event);
|
||||
this.pendingPromises.delete(event.id);
|
||||
}
|
||||
this.cache.get(event.id).next(event);
|
||||
}
|
||||
|
||||
private batchRequestsThrottle = _throttle(this.batchRequests, 1000 * 2);
|
||||
batchRequests() {
|
||||
if (this.pending.size === 0) return;
|
||||
|
||||
@ -48,8 +47,4 @@ class SingleEventService {
|
||||
|
||||
const singleEventService = new SingleEventService();
|
||||
|
||||
setInterval(() => {
|
||||
singleEventService.batchRequests();
|
||||
}, 1000);
|
||||
|
||||
export default singleEventService;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { TimelineLoader } from "../classes/timeline-loader";
|
||||
|
||||
const MAX_CACHE = 10;
|
||||
const MAX_CACHE = 20;
|
||||
|
||||
class TimelineCacheService {
|
||||
private timelines = new Map<string, TimelineLoader>();
|
||||
|
@ -4,7 +4,7 @@ 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 replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
export type UserContacts = {
|
||||
@ -55,16 +55,10 @@ class UserContactsService {
|
||||
getSubject(pubkey: string) {
|
||||
return this.subjects.get(pubkey);
|
||||
}
|
||||
requestContacts(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
requestContacts(pubkey: string, relays: string[], opts?: RequestOptions) {
|
||||
const sub = this.subjects.get(pubkey);
|
||||
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(
|
||||
relays,
|
||||
Kind.Contacts,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest,
|
||||
);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(relays, Kind.Contacts, pubkey, undefined, opts);
|
||||
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseContacts(event)));
|
||||
|
||||
|
@ -3,7 +3,7 @@ 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 replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
class UserMetadataService {
|
||||
@ -25,15 +25,9 @@ class UserMetadataService {
|
||||
getSubject(pubkey: string) {
|
||||
return this.parsedSubjects.get(pubkey);
|
||||
}
|
||||
requestMetadata(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
requestMetadata(pubkey: string, relays: string[], opts: RequestOptions = {}) {
|
||||
const sub = this.parsedSubjects.get(pubkey);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(
|
||||
relays,
|
||||
Kind.Metadata,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest,
|
||||
);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(relays, Kind.Metadata, pubkey, undefined, opts);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseKind0Event(event)));
|
||||
return sub;
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import { isRTag, NostrEvent } from "../types/nostr-event";
|
||||
import { RelayConfig } from "../classes/relay";
|
||||
import { parseRTag } from "../helpers/nostr/events";
|
||||
@ -5,8 +7,7 @@ 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";
|
||||
import replaceableEventLoaderService, { RequestOptions } from "./replaceable-event-requester";
|
||||
|
||||
export type ParsedUserRelays = {
|
||||
pubkey: string;
|
||||
@ -27,19 +28,13 @@ class UserRelaysService {
|
||||
getRelays(pubkey: string) {
|
||||
return this.subjects.get(pubkey);
|
||||
}
|
||||
requestRelays(pubkey: string, relays: string[], alwaysRequest = false) {
|
||||
requestRelays(pubkey: string, relays: string[], opts: RequestOptions = {}) {
|
||||
const sub = this.subjects.get(pubkey);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(
|
||||
relays,
|
||||
Kind.RelayList,
|
||||
pubkey,
|
||||
undefined,
|
||||
alwaysRequest,
|
||||
);
|
||||
const requestSub = replaceableEventLoaderService.requestEvent(relays, Kind.RelayList, pubkey, undefined, opts);
|
||||
sub.connectWithHandler(requestSub, (event, next) => next(parseRelaysEvent(event)));
|
||||
|
||||
// also fetch the relays from the users contacts
|
||||
const contactsSub = userContactsService.requestContacts(pubkey, relays, alwaysRequest);
|
||||
const contactsSub = userContactsService.requestContacts(pubkey, relays, opts);
|
||||
sub.connectWithHandler(contactsSub, (contacts, next, value) => {
|
||||
if (contacts.relays.length > 0 && (!value || contacts.created_at > value.created_at)) {
|
||||
next({ pubkey: contacts.pubkey, relays: contacts.relays, created_at: contacts.created_at });
|
||||
|
@ -3,13 +3,19 @@ import { defineStyle, defineStyleConfig } from "@chakra-ui/react";
|
||||
// define custom sizes
|
||||
const sizes = {
|
||||
sm: defineStyle({
|
||||
maxW: "10rem",
|
||||
maxW: "30em",
|
||||
}),
|
||||
md: defineStyle({
|
||||
maxW: "50rem",
|
||||
maxW: "48em",
|
||||
}),
|
||||
lg: defineStyle({
|
||||
maxW: "100rem",
|
||||
maxW: "62em",
|
||||
}),
|
||||
xl: defineStyle({
|
||||
maxW: "80em",
|
||||
}),
|
||||
"2xl": defineStyle({
|
||||
maxW: "96em",
|
||||
}),
|
||||
};
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { extendTheme } from "@chakra-ui/react";
|
||||
import { containerTheme } from "./container";
|
||||
|
||||
export default function createTheme(primaryColor: string = "#8DB600") {
|
||||
return extendTheme({
|
||||
const breakpoints = ["sm", "md", "lg", "xl", "2xl"] as const;
|
||||
|
||||
export default function createTheme(primaryColor: string = "#8DB600", maxBreakpoint?: (typeof breakpoints)[number]) {
|
||||
const theme = extendTheme({
|
||||
colors: {
|
||||
brand: {
|
||||
50: primaryColor,
|
||||
@ -21,4 +23,13 @@ export default function createTheme(primaryColor: string = "#8DB600") {
|
||||
Container: containerTheme,
|
||||
},
|
||||
});
|
||||
|
||||
// if maxBreakpoint is set, set all breakpoints above it to a large number so they are never reached
|
||||
if (maxBreakpoint && breakpoints.includes(maxBreakpoint)) {
|
||||
for (let i = breakpoints.indexOf(maxBreakpoint) + 1; i < breakpoints.length; i++) {
|
||||
theme.breakpoints[breakpoints[i]] = 50000;
|
||||
}
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import { ArrowLeftSIcon } from "../../components/icons";
|
||||
import { useDeleteEventContext } from "../../providers/delete-event-provider";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import { EventRelays } from "../../components/note/note-relays";
|
||||
import { getBadgeDescription, getBadgeImage, getBadgeName } from "../../helpers/nostr/badges";
|
||||
import { getBadgeAwardPubkey, getBadgeDescription, getBadgeImage, getBadgeName } from "../../helpers/nostr/badges";
|
||||
import BadgeMenu from "./components/badge-menu";
|
||||
import BadgeAwardCard from "./components/award-card";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
@ -21,6 +21,7 @@ import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||
import { UserLink } from "../../components/user-link";
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
import Timestamp from "../../components/timestamp";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
|
||||
function BadgeDetailsPage({ badge }: { badge: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
@ -44,60 +45,62 @@ function BadgeDetailsPage({ badge }: { badge: NostrEvent }) {
|
||||
|
||||
const isAuthor = account?.pubkey === badge.pubkey;
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex direction="column" px="2" pt="2" pb="8" overflow="hidden" h="full" gap="2">
|
||||
<Flex gap="2" alignItems="center">
|
||||
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
|
||||
Back
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<UserAvatarLink pubkey={badge.pubkey} size="sm" />
|
||||
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
||||
<Text>|</Text>
|
||||
<Heading size="md">{getBadgeName(badge)}</Heading>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<EventRelays event={badge} />
|
||||
|
||||
{isAuthor && (
|
||||
<Button colorScheme="red" onClick={() => deleteEvent(badge).then(() => navigate("/lists"))}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<BadgeMenu aria-label="More options" badge={badge} />
|
||||
</Flex>
|
||||
|
||||
<UserAvatarLink pubkey={badge.pubkey} size="sm" />
|
||||
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
||||
<Text>|</Text>
|
||||
<Flex direction={{ base: "column", lg: "row" }} gap="2">
|
||||
{image && <Image src={image.src} maxW="3in" mr="2" mb="2" mx={{ base: "auto", lg: "initial" }} />}
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="md">{getBadgeName(badge)}</Heading>
|
||||
|
||||
<Spacer />
|
||||
|
||||
<EventRelays event={badge} />
|
||||
|
||||
{isAuthor && (
|
||||
<Button colorScheme="red" onClick={() => deleteEvent(badge).then(() => navigate("/lists"))}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<BadgeMenu aria-label="More options" badge={badge} />
|
||||
<Text>
|
||||
Created by: <UserAvatarLink pubkey={badge.pubkey} size="xs" />{" "}
|
||||
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
||||
</Text>
|
||||
<Text>
|
||||
Last Updated: <Timestamp timestamp={badge.created_at} />
|
||||
</Text>
|
||||
{description && <Text pb="2">{description}</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex direction={{ base: "column", lg: "row" }} gap="2">
|
||||
{image && <Image src={image.src} maxW="3in" mr="2" mb="2" mx={{ base: "auto", lg: "initial" }} />}
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading size="md">{getBadgeName(badge)}</Heading>
|
||||
<Text>
|
||||
Created by: <UserAvatarLink pubkey={badge.pubkey} size="xs" />{" "}
|
||||
<UserLink fontWeight="bold" pubkey={badge.pubkey} />
|
||||
</Text>
|
||||
<Text>
|
||||
Last Updated: <Timestamp timestamp={badge.created_at} />
|
||||
</Text>
|
||||
{description && <Text pb="2">{description}</Text>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{awards.length > 0 && (
|
||||
<>
|
||||
{awards.length > 0 && (
|
||||
<>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Heading size="md">Awarded to</Heading>
|
||||
<Divider />
|
||||
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
|
||||
{awards.map((award) => (
|
||||
<ErrorBoundary>
|
||||
<BadgeAwardCard award={award} />
|
||||
</ErrorBoundary>
|
||||
<>
|
||||
{getBadgeAwardPubkey(award).map(({ pubkey }) => (
|
||||
<BadgeAwardCard award={award} pubkey={pubkey} />
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</IntersectionObserverProvider>
|
||||
</IntersectionObserverProvider>
|
||||
</>
|
||||
)}
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import BadgeCard from "./components/badge-card";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
|
||||
function BadgesBrowsePage() {
|
||||
const { filter, listId } = usePeopleListContext();
|
||||
@ -27,7 +28,7 @@ function BadgesBrowsePage() {
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex direction="column" gap="2" p="2">
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<PeopleListSelection />
|
||||
</Flex>
|
||||
@ -37,7 +38,7 @@ function BadgesBrowsePage() {
|
||||
<BadgeCard key={getEventUID(badge)} badge={badge} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
</IntersectionObserverProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,16 +1,23 @@
|
||||
import { useRef } from "react";
|
||||
import { Card, CardBody, CardProps, Flex, Heading } from "@chakra-ui/react";
|
||||
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { getBadgeAwardPubkey } from "../../../helpers/nostr/badges";
|
||||
import { UserLink } from "../../../components/user-link";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
import { getEventUID } from "../../../helpers/nostr/events";
|
||||
|
||||
export default function BadgeAwardCard({ award, ...props }: Omit<CardProps, "children"> & { award: NostrEvent }) {
|
||||
const pubkey = getBadgeAwardPubkey(award);
|
||||
export default function BadgeAwardCard({
|
||||
pubkey,
|
||||
award,
|
||||
...props
|
||||
}: Omit<CardProps, "children"> & { award: NostrEvent; pubkey: string }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(award));
|
||||
|
||||
return (
|
||||
<Card {...props}>
|
||||
<Card {...props} ref={ref}>
|
||||
<CardBody p="2" display="flex" alignItems="center" overflow="hidden" gap="2">
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<Flex direction="column" flex={1} overflow="hidden">
|
||||
|
@ -1,18 +1,16 @@
|
||||
import { Button, Flex, Heading, Image, Link, SimpleGrid, Spacer, useDisclosure } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Button, Flex, Image, Link, Spacer } from "@chakra-ui/react";
|
||||
import { Navigate, Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { ExternalLinkIcon } from "../../components/icons";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import BadgeCard from "./components/badge-card";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
|
||||
function BadgesPage() {
|
||||
const account = useCurrentAccount()!;
|
||||
|
||||
return (
|
||||
<Flex direction="column" pt="2" pb="10" gap="2" px={["2", "2", 0]}>
|
||||
<Flex gap="2">
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<Button as={RouterLink} to="/badges/browse">
|
||||
Browse Badges
|
||||
</Button>
|
||||
@ -27,26 +25,11 @@ function BadgesPage() {
|
||||
Badges
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* {peopleLists.length > 0 && (
|
||||
<>
|
||||
<Heading size="md">People lists</Heading>
|
||||
<Divider />
|
||||
<SimpleGrid columns={{ base: 1, lg: 2, xl: 3 }} spacing="2">
|
||||
{peopleLists.map((event) => (
|
||||
<BadgeCard key={getEventUID(event)} badge={event} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)} */}
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BadgesView() {
|
||||
return (
|
||||
<RequireCurrentAccount>
|
||||
<BadgesPage />
|
||||
</RequireCurrentAccount>
|
||||
);
|
||||
const account = useCurrentAccount();
|
||||
return account ? <BadgesPage /> : <Navigate to="/lists/browse" />;
|
||||
}
|
||||
|
59
src/views/communities/components/community-card.tsx
Normal file
59
src/views/communities/components/community-card.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { memo, useRef } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Box, Card, CardProps, Center, Flex, Heading, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
|
||||
|
||||
import { UserAvatarLink } from "../../../components/user-avatar-link";
|
||||
import { UserLink } from "../../../components/user-link";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
import { getEventUID } from "../../../helpers/nostr/events";
|
||||
import { getCommunityImage, getCommunityName } from "../../../helpers/nostr/communities";
|
||||
import CommunityDescription from "./community-description";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import CommunityModList from "./community-mod-list";
|
||||
|
||||
function CommunityCard({ community, ...props }: Omit<CardProps, "children"> & { community: NostrEvent }) {
|
||||
// if there is a parent intersection observer, register this card
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(community));
|
||||
|
||||
const image = getCommunityImage(community);
|
||||
|
||||
return (
|
||||
<Card as={LinkBox} ref={ref} variant="outline" gap="2" overflow="hidden" {...props}>
|
||||
{image ? (
|
||||
<Box
|
||||
backgroundImage={getCommunityImage(community)}
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
aspectRatio={3 / 1}
|
||||
/>
|
||||
) : (
|
||||
<Center aspectRatio={3 / 1} fontWeight="bold" fontSize="2xl">
|
||||
{getCommunityName(community)}
|
||||
</Center>
|
||||
)}
|
||||
<Flex direction="column" flex={1} px="2" pb="2">
|
||||
<Flex wrap="wrap" gap="2" alignItems="center">
|
||||
<Heading size="lg">
|
||||
<LinkOverlay
|
||||
as={RouterLink}
|
||||
to={`/c/${encodeURIComponent(getCommunityName(community))}/${nip19.npubEncode(community.pubkey)}`}
|
||||
>
|
||||
{getCommunityName(community)}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text>Created by:</Text>
|
||||
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
|
||||
</Flex>
|
||||
<CommunityDescription community={community} maxLength={128} flex={1} />
|
||||
<Flex gap="2">
|
||||
<CommunityModList community={community} ml="auto" size="xs" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CommunityCard);
|
25
src/views/communities/components/community-description.tsx
Normal file
25
src/views/communities/components/community-description.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { getCommunityDescription } from "../../../helpers/nostr/communities";
|
||||
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../../../helpers/embeds";
|
||||
import { renderGenericUrl } from "../../../components/embed-types";
|
||||
|
||||
export default function CommunityDescription({
|
||||
community,
|
||||
maxLength,
|
||||
...props
|
||||
}: Omit<BoxProps, "children"> & { community: NostrEvent; maxLength?: number }) {
|
||||
const description = getCommunityDescription(community);
|
||||
let content: EmbedableContent = description ? [description] : [];
|
||||
|
||||
content = embedUrls(content, [renderGenericUrl]);
|
||||
if (maxLength !== undefined) {
|
||||
content = truncateEmbedableContent(content, maxLength);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
20
src/views/communities/components/community-mod-list.tsx
Normal file
20
src/views/communities/components/community-mod-list.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { AvatarGroup, AvatarGroupProps } from "@chakra-ui/react";
|
||||
|
||||
import { UserAvatarLink } from "../../../components/user-avatar-link";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { getCommunityMods } from "../../../helpers/nostr/communities";
|
||||
|
||||
export default function CommunityModList({
|
||||
community,
|
||||
...props
|
||||
}: Omit<AvatarGroupProps, "children"> & { community: NostrEvent }) {
|
||||
const mods = getCommunityMods(community);
|
||||
|
||||
return (
|
||||
<AvatarGroup {...props}>
|
||||
{mods.map((pubkey) => (
|
||||
<UserAvatarLink pubkey={pubkey} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
);
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import { useCallback } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { Button, ButtonProps, useToast } from "@chakra-ui/react";
|
||||
|
||||
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
|
||||
import useSubscribedCommunitiesList from "../../../hooks/use-subscribed-communities-list";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import { SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER, getCommunityName } from "../../../helpers/nostr/communities";
|
||||
import { NOTE_LIST_KIND, listAddCoordinate, listRemoveCoordinate } from "../../../helpers/nostr/lists";
|
||||
import { getEventCoordinate } from "../../../helpers/nostr/events";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import clientRelaysService from "../../../services/client-relays";
|
||||
|
||||
export default function CommunityJoinButton({
|
||||
community,
|
||||
...props
|
||||
}: Omit<ButtonProps, "children"> & { community: NostrEvent }) {
|
||||
const account = useCurrentAccount();
|
||||
const { list, pointers } = useSubscribedCommunitiesList(account?.pubkey);
|
||||
const { requestSignature } = useSigningContext();
|
||||
const toast = useToast();
|
||||
|
||||
const isSubscribed = pointers.find(
|
||||
(cord) => cord.identifier === getCommunityName(community) && cord.pubkey === community.pubkey,
|
||||
);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
const favList = list || {
|
||||
kind: NOTE_LIST_KIND,
|
||||
content: "",
|
||||
created_at: dayjs().unix(),
|
||||
tags: [["d", SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER]],
|
||||
};
|
||||
|
||||
let draft: DraftNostrEvent;
|
||||
if (isSubscribed) {
|
||||
draft = listRemoveCoordinate(favList, getEventCoordinate(community));
|
||||
} else {
|
||||
draft = listAddCoordinate(favList, getEventCoordinate(community));
|
||||
}
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
|
||||
new NostrPublishAction(isSubscribed ? "Unsubscribe" : "Subscribe", clientRelaysService.getWriteUrls(), signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
}, [isSubscribed, list, community]);
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} {...props}>
|
||||
{isSubscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</Button>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user