mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 21:00:17 +02:00
Merge branch 'next'
This commit is contained in:
commit
7d0da2fc6d
5
.changeset/beige-cougars-float.md
Normal file
5
.changeset/beige-cougars-float.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add time durations for muting users
|
5
.changeset/chilly-carrots-knock.md
Normal file
5
.changeset/chilly-carrots-knock.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add simple stream moderation tool
|
5
.changeset/curly-fans-poke.md
Normal file
5
.changeset/curly-fans-poke.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix issue with freezing when navigating back to main timeline
|
5
.changeset/friendly-needles-flash.md
Normal file
5
.changeset/friendly-needles-flash.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add popular relays view
|
5
.changeset/honest-hornets-smoke.md
Normal file
5
.changeset/honest-hornets-smoke.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix broken links in side drawer
|
5
.changeset/quiet-clocks-sell.md
Normal file
5
.changeset/quiet-clocks-sell.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add nostr.build image uploads
|
5
.changeset/soft-fishes-heal.md
Normal file
5
.changeset/soft-fishes-heal.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix bug when clicking on shared long form note
|
5
.changeset/spotty-pumas-yawn.md
Normal file
5
.changeset/spotty-pumas-yawn.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add simple community views
|
5
.changeset/twelve-bananas-begin.md
Normal file
5
.changeset/twelve-bananas-begin.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add image upload button to reply form
|
5
.changeset/twelve-wombats-perform.md
Normal file
5
.changeset/twelve-wombats-perform.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add ghost mode
|
12
package.json
12
package.json
@ -18,6 +18,7 @@
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@getalby/bitcoin-connect-react": "^1.1.0",
|
||||
"@types/three": "^0.156.0",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"bech32": "^2.0.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
@ -36,16 +37,21 @@
|
||||
"nanoid": "^4.0.2",
|
||||
"ngeohash": "^0.6.3",
|
||||
"noble-secp256k1": "^1.2.14",
|
||||
"nostr-tools": "^1.14.0",
|
||||
"nostr-tools": "^1.15.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-force-graph-2d": "^1.25.1",
|
||||
"react-force-graph-3d": "^1.23.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-photo-album": "^2.3.0",
|
||||
"react-qr-barcode-scanner": "^1.0.6",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"three": "^0.156.1",
|
||||
"three-spritetext": "^1.8.1",
|
||||
"webln": "^0.3.2",
|
||||
"yet-another-react-lightbox": "^3.12.1"
|
||||
},
|
||||
@ -58,7 +64,7 @@
|
||||
"@types/leaflet.locatecontrol": "^0.74.1",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/ngeohash": "^0.6.4",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react": "^18.2.22",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
@ -69,7 +75,7 @@
|
||||
"vite-plugin-pwa": "^0.16.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react": "^18.2.22",
|
||||
"@types/react-dom": "^18.2.7"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 185 KiB |
BIN
screenshots/drawer.png
Normal file
BIN
screenshots/drawer.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 508 KiB |
BIN
screenshots/photography.png
Normal file
BIN
screenshots/photography.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 MiB |
BIN
screenshots/profile.png
Normal file
BIN
screenshots/profile.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 350 KiB |
BIN
screenshots/streaming.png
Normal file
BIN
screenshots/streaming.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 MiB |
BIN
screenshots/streams.png
Normal file
BIN
screenshots/streams.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 MiB |
17
src/app.tsx
17
src/app.tsx
@ -49,7 +49,6 @@ import GoalsView from "./views/goals";
|
||||
import GoalsBrowseView from "./views/goals/browse";
|
||||
import GoalDetailsView from "./views/goals/goal-details";
|
||||
import UserGoalsTab from "./views/user/goals";
|
||||
import NetworkView from "./views/tools/network";
|
||||
import MutedByView from "./views/user/muted-by";
|
||||
import BadgesView from "./views/badges";
|
||||
import BadgesBrowseView from "./views/badges/browse";
|
||||
@ -59,7 +58,11 @@ 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";
|
||||
import StreamModerationView from "./views/tools/stream-moderation";
|
||||
import PopularRelaysView from "./views/relays/popular";
|
||||
|
||||
const NetworkView = React.lazy(() => import("./views/tools/network"));
|
||||
const NetworkGraphView = React.lazy(() => import("./views/tools/network-mute-graph"));
|
||||
const StreamsView = React.lazy(() => import("./views/streams"));
|
||||
const StreamView = React.lazy(() => import("./views/streams/stream"));
|
||||
const SearchView = React.lazy(() => import("./views/search"));
|
||||
@ -158,8 +161,14 @@ const router = createHashRouter([
|
||||
element: <NoteView />,
|
||||
},
|
||||
{ path: "settings", element: <SettingsView /> },
|
||||
{ path: "relays/reviews", element: <RelayReviewsView /> },
|
||||
{ path: "relays", element: <RelaysView /> },
|
||||
{
|
||||
path: "relays",
|
||||
children: [
|
||||
{ path: "", element: <RelaysView /> },
|
||||
{ path: "popular", element: <PopularRelaysView /> },
|
||||
{ path: "reviews", element: <RelayReviewsView /> },
|
||||
],
|
||||
},
|
||||
{ path: "r/:relay", element: <RelayView /> },
|
||||
{ path: "notifications", element: <NotificationsView /> },
|
||||
{ path: "search", element: <SearchView /> },
|
||||
@ -171,6 +180,8 @@ const router = createHashRouter([
|
||||
children: [
|
||||
{ path: "", element: <ToolsHomeView /> },
|
||||
{ path: "network", element: <NetworkView /> },
|
||||
{ path: "network-graph", element: <NetworkGraphView /> },
|
||||
{ path: "stream-moderation", element: <StreamModerationView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -13,6 +13,7 @@ export default function EmbeddedCommunity({
|
||||
...props
|
||||
}: Omit<CardProps, "children"> & { community: NostrEvent }) {
|
||||
const image = getCommunityImage(community);
|
||||
const name = getCommunityName(community);
|
||||
|
||||
return (
|
||||
<Card as={LinkBox} variant="outline" gap="2" overflow="hidden" {...props}>
|
||||
@ -20,23 +21,20 @@ export default function EmbeddedCommunity({
|
||||
<Box
|
||||
backgroundImage={getCommunityImage(community)}
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundSize="cover"
|
||||
backgroundSize="contain"
|
||||
backgroundPosition="center"
|
||||
aspectRatio={3 / 1}
|
||||
/>
|
||||
) : (
|
||||
<Center aspectRatio={4 / 1} fontWeight="bold" fontSize="2xl">
|
||||
{getCommunityName(community)}
|
||||
{name}
|
||||
</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 as={RouterLink} to={`/c/${encodeURIComponent(name)}/${nip19.npubEncode(community.pubkey)}`}>
|
||||
{name}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text>Created by:</Text>
|
||||
|
@ -35,6 +35,7 @@ export function embedNostrLinks(content: EmbedableContent) {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function embedNostrMentions(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
|
||||
return embedJSX(content, {
|
||||
name: "nostr-mention",
|
||||
|
@ -427,3 +427,9 @@ export const CommunityIcon = createIcon({
|
||||
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,
|
||||
});
|
||||
|
||||
export const GhostIcon = createIcon({
|
||||
displayName: "GhostIcon",
|
||||
d: "M12 2C16.9706 2 21 6.02944 21 11V18.5C21 20.433 19.433 22 17.5 22C16.3001 22 15.2413 21.3962 14.6107 20.476C14.0976 21.3857 13.1205 22 12 22C10.8795 22 9.9024 21.3857 9.38728 20.4754C8.75869 21.3962 7.69985 22 6.5 22C4.63144 22 3.10487 20.5357 3.00518 18.692L3 18.5V11C3 6.02944 7.02944 2 12 2ZM12 4C8.21455 4 5.1309 7.00478 5.00406 10.7593L5 11L4.99927 18.4461L5.00226 18.584C5.04504 19.3751 5.70251 20 6.5 20C6.95179 20 7.36652 19.8007 7.64704 19.4648L7.73545 19.3478C8.57033 18.1248 10.3985 18.2016 11.1279 19.4904C11.3053 19.8038 11.6345 20 12 20C12.3651 20 12.6933 19.8044 12.8687 19.4934C13.5692 18.2516 15.2898 18.1317 16.1636 19.2151L16.2606 19.3455C16.5401 19.7534 16.9976 20 17.5 20C18.2797 20 18.9204 19.4051 18.9931 18.6445L19 18.5V11C19 7.13401 15.866 4 12 4ZM12 12C13.1046 12 14 13.1193 14 14.5C14 15.8807 13.1046 17 12 17C10.8954 17 10 15.8807 10 14.5C10 13.1193 10.8954 12 12 12ZM9.5 8C10.3284 8 11 8.67157 11 9.5C11 10.3284 10.3284 11 9.5 11C8.67157 11 8 10.3284 8 9.5C8 8.67157 8.67157 8 9.5 8ZM14.5 8C15.3284 8 16 8.67157 16 9.5C16 10.3284 15.3284 11 14.5 11C13.6716 11 13 10.3284 13 9.5C13 8.67157 13.6716 8 14.5 8Z",
|
||||
defaultProps,
|
||||
});
|
||||
|
@ -10,6 +10,15 @@ import AccountSwitcher from "./account-switcher";
|
||||
import { PostModalContext } from "../../providers/post-modal-provider";
|
||||
import PublishLog from "../publish-log";
|
||||
import NavItems from "./nav-items";
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
const hideScrollbar = css`
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
const account = useCurrentAccount();
|
||||
@ -27,6 +36,7 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
h="100vh"
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
css={hideScrollbar}
|
||||
>
|
||||
<Flex direction="column" flexShrink={0} gap="2">
|
||||
<Flex gap="2" alignItems="center" position="relative">
|
||||
@ -38,17 +48,19 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
||||
{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}
|
||||
/>
|
||||
{!account.readonly && (
|
||||
<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">
|
||||
|
127
src/components/layout/ghost-toolbar.tsx
Normal file
127
src/components/layout/ghost-toolbar.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Box, BoxProps, Card, CloseButton, Divider, Flex, FlexProps, Spacer, Text } from "@chakra-ui/react";
|
||||
import { Kind, nip18, nip19, nip25 } from "nostr-tools";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useInterval } from "react-use";
|
||||
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import accountService from "../../services/account";
|
||||
import { UserAvatar } from "../user-avatar";
|
||||
import { UserLink } from "../user-link";
|
||||
import { GhostIcon } from "../icons";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import dayjs from "dayjs";
|
||||
import { TimelineLoader } from "../../classes/timeline-loader";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
import { safeRelayUrls } from "../../helpers/url";
|
||||
|
||||
const kindColors: Record<number, FlexProps["bg"]> = {
|
||||
[Kind.Text]: "blue.500",
|
||||
[Kind.RecommendRelay]: "pink",
|
||||
[Kind.EncryptedDirectMessage]: "orange.500",
|
||||
[Kind.Repost]: "yellow",
|
||||
[Kind.Reaction]: "green.500",
|
||||
[Kind.Article]: "purple.500",
|
||||
};
|
||||
|
||||
function EventChunk({ event, ...props }: { event: NostrEvent } & Omit<FlexProps, "children">) {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = useCallback(() => {
|
||||
switch (event.kind) {
|
||||
case Kind.Reaction: {
|
||||
const pointer = nip25.getReactedEventPointer(event);
|
||||
if (pointer) navigate(`/l/${nip19.neventEncode(pointer)}`);
|
||||
return;
|
||||
}
|
||||
case Kind.Repost: {
|
||||
const pointer = nip18.getRepostedEventPointer(event);
|
||||
if (pointer?.relays) pointer.relays = safeRelayUrls(pointer.relays);
|
||||
if (pointer) navigate(`/l/${nip19.neventEncode(pointer)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
navigate(`/l/${getSharableEventAddress(event)}`);
|
||||
}, [event]);
|
||||
|
||||
const getTitle = () => {
|
||||
switch (event.kind) {
|
||||
case Kind.Text:
|
||||
return "Note";
|
||||
case Kind.Reaction:
|
||||
return "Reaction";
|
||||
case Kind.EncryptedDirectMessage:
|
||||
return "Direct Message";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex alignItems="center" cursor="pointer" onClick={handleClick} title={getTitle()} overflow="hidden" {...props}>
|
||||
<Box bg={kindColors[event.kind] || "gray.500"} h="8" p="2" fontSize="sm">
|
||||
{getTitle()}
|
||||
</Box>
|
||||
<Divider />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactEventTimeline({ timeline, ...props }: { timeline: TimelineLoader } & Omit<FlexProps, "children">) {
|
||||
const events = useSubject(timeline.timeline);
|
||||
const [now, setNow] = useState(dayjs().unix());
|
||||
|
||||
useInterval(() => setNow(dayjs().unix()), 1000 * 10);
|
||||
|
||||
return (
|
||||
<Flex {...props}>
|
||||
{Array.from(events)
|
||||
.reverse()
|
||||
.map((event, i, arr) => {
|
||||
const next = arr[i + 1];
|
||||
return (
|
||||
<EventChunk
|
||||
key={event.id}
|
||||
event={event}
|
||||
flex={next ? next.created_at - event.created_at : now - event.created_at}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GhostToolbar() {
|
||||
const account = useCurrentAccount()!;
|
||||
const isGhost = useSubject(accountService.isGhost);
|
||||
|
||||
const readRelays = useReadRelayUrls();
|
||||
const [since] = useState(dayjs().subtract(6, "hours").unix());
|
||||
|
||||
const timeline = useTimelineLoader(`${account.pubkey}-ghost`, readRelays, { since, authors: [account.pubkey] });
|
||||
|
||||
const events = useSubject(timeline.timeline);
|
||||
|
||||
return (
|
||||
<Card
|
||||
p="2"
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
gap="2"
|
||||
position="fixed"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
>
|
||||
<GhostIcon fontSize="2rem" />
|
||||
<Text>Ghosting: </Text>
|
||||
<UserAvatar pubkey={account.pubkey} size="sm" />
|
||||
<UserLink pubkey={account.pubkey} fontWeight="bold" />
|
||||
<Spacer />
|
||||
<CompactEventTimeline w="70%" timeline={timeline} />
|
||||
<Spacer />
|
||||
<CloseButton onClick={() => accountService.stopGhost()} />
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -5,9 +5,13 @@ import { ErrorBoundary } from "../error-boundary";
|
||||
import { ReloadPrompt } from "../reload-prompt";
|
||||
import DesktopSideNav from "./desktop-side-nav";
|
||||
import MobileBottomNav from "./mobile-bottom-nav";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import accountService from "../../services/account";
|
||||
import GhostToolbar from "./ghost-toolbar";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const isGhost = useSubject(accountService.isGhost);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -41,6 +45,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
<Spacer display={["none", null, "block"]} />
|
||||
</Flex>
|
||||
{isGhost && <GhostToolbar />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -101,6 +101,14 @@ export default function NavItems() {
|
||||
>
|
||||
Streams
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/communities")}
|
||||
leftIcon={<CommunityIcon />}
|
||||
colorScheme={active === "communities" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Communities
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/lists")}
|
||||
leftIcon={<ListIcon />}
|
||||
@ -109,14 +117,6 @@ export default function NavItems() {
|
||||
>
|
||||
Lists
|
||||
</Button>
|
||||
{/* <Button
|
||||
onClick={() => navigate("/communities")}
|
||||
leftIcon={<CommunityIcon />}
|
||||
colorScheme={active === "communities" ? "brand" : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
Communities
|
||||
</Button> */}
|
||||
<Button
|
||||
onClick={() => navigate("/goals")}
|
||||
leftIcon={<GoalIcon />}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { TextareaHTMLAttributes } from "react";
|
||||
import { Image, Input, InputProps, Textarea, TextareaProps } from "@chakra-ui/react";
|
||||
import React, { LegacyRef } from "react";
|
||||
import { Image, InputProps, Textarea, TextareaProps, Input } from "@chakra-ui/react";
|
||||
import ReactTextareaAutocomplete, {
|
||||
ItemComponentProps,
|
||||
TextareaProps as ReactTextareaAutocompleteProps,
|
||||
@ -96,32 +96,35 @@ function useAutocompleteTriggers() {
|
||||
return triggers;
|
||||
}
|
||||
|
||||
export function MagicInput({ ...props }: InputProps) {
|
||||
// @ts-ignore
|
||||
export type RefType = ReactTextareaAutocomplete<Token, TextareaProps>;
|
||||
|
||||
export function MagicInput({ instanceRef, ...props }: InputProps & { instanceRef?: LegacyRef<RefType> }) {
|
||||
const triggers = useAutocompleteTriggers();
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<ReactTextareaAutocomplete<Token, InputProps>
|
||||
textAreaComponent={Input}
|
||||
{...props}
|
||||
textAreaComponent={Input}
|
||||
ref={instanceRef}
|
||||
loadingComponent={Loading}
|
||||
renderToBody
|
||||
minChar={0}
|
||||
trigger={triggers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MagicTextArea({ ...props }: TextareaProps) {
|
||||
export default function MagicTextArea({ instanceRef, ...props }: TextareaProps & { instanceRef?: LegacyRef<RefType> }) {
|
||||
const triggers = useAutocompleteTriggers();
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<ReactTextareaAutocomplete<Token, TextareaProps>
|
||||
{...props}
|
||||
ref={instanceRef}
|
||||
textAreaComponent={Textarea}
|
||||
loadingComponent={Loading}
|
||||
renderToBody
|
||||
minChar={0}
|
||||
trigger={triggers}
|
||||
/>
|
||||
|
@ -5,11 +5,11 @@ export type MenuIconButtonProps = IconButtonProps & {
|
||||
children: MenuListProps["children"];
|
||||
};
|
||||
|
||||
export function MenuIconButton({ children, ...props }: MenuIconButtonProps) {
|
||||
export function CustomMenuIconButton({ children, ...props }: MenuIconButtonProps) {
|
||||
return (
|
||||
<Menu isLazy>
|
||||
<MenuButton as={IconButton} icon={<MoreIcon />} {...props} />
|
||||
<MenuList>{children}</MenuList>
|
||||
<MenuList zIndex={100}>{children}</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
@ -10,11 +10,13 @@ import {
|
||||
Flex,
|
||||
IconButton,
|
||||
Link,
|
||||
Text,
|
||||
useBreakpointValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { NostrEvent, isATag } from "../../types/nostr-event";
|
||||
import { UserAvatarLink } from "../user-avatar-link";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { NoteMenu } from "./note-menu";
|
||||
import { EventRelays } from "./note-relays";
|
||||
@ -36,19 +38,29 @@ import BookmarkButton from "./components/bookmark-button";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import NoteReactions from "./components/note-reactions";
|
||||
import ReplyForm from "../../views/note/components/reply-form";
|
||||
import { getReferences } from "../../helpers/nostr/events";
|
||||
import { getEventCoordinate, getReferences, parseCoordinate } from "../../helpers/nostr/events";
|
||||
import Timestamp from "../timestamp";
|
||||
import OpenInDrawerButton from "../open-in-drawer-button";
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
import { COMMUNITY_DEFINITION_KIND, getCommunityName } from "../../helpers/nostr/communities";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
|
||||
export type NoteProps = Omit<CardProps, "children"> & {
|
||||
event: NostrEvent;
|
||||
variant?: CardProps["variant"];
|
||||
showReplyButton?: boolean;
|
||||
hideDrawerButton?: boolean;
|
||||
registerIntersectionEntity?: boolean;
|
||||
};
|
||||
export const Note = React.memo(
|
||||
({ event, variant = "outline", showReplyButton, hideDrawerButton, ...props }: NoteProps) => {
|
||||
({
|
||||
event,
|
||||
variant = "outline",
|
||||
showReplyButton,
|
||||
hideDrawerButton,
|
||||
registerIntersectionEntity = true,
|
||||
...props
|
||||
}: NoteProps) => {
|
||||
const account = useCurrentAccount();
|
||||
const { showReactions, showSignatureVerification } = useSubject(appSettings);
|
||||
const replyForm = useDisclosure();
|
||||
@ -59,6 +71,11 @@ export const Note = React.memo(
|
||||
|
||||
// find mostr external link
|
||||
const externalLink = useMemo(() => event.tags.find((t) => t[0] === "mostr" || t[0] === "proxy"), [event])?.[1];
|
||||
const communityPointer = useMemo(() => {
|
||||
const tag = event.tags.find((t) => isATag(t) && t[1].startsWith(COMMUNITY_DEFINITION_KIND + ":"));
|
||||
return tag?.[1] ? parseCoordinate(tag[1], true) : undefined;
|
||||
}, [event]);
|
||||
const community = useReplaceableEvent(communityPointer);
|
||||
|
||||
const showReactionsOnNewLine = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
@ -67,8 +84,13 @@ export const Note = React.memo(
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<ExpandProvider>
|
||||
<Card variant={variant} ref={ref} data-event-id={event.id} {...props}>
|
||||
<CardHeader padding="2">
|
||||
<Card
|
||||
variant={variant}
|
||||
ref={registerIntersectionEntity ? ref : undefined}
|
||||
data-event-id={event.id}
|
||||
{...props}
|
||||
>
|
||||
<CardHeader p="2">
|
||||
<Flex flex="1" gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={event.pubkey} size={["xs", "sm"]} />
|
||||
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
|
||||
@ -82,6 +104,15 @@ export const Note = React.memo(
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
</NoteLink>
|
||||
</Flex>
|
||||
{community && (
|
||||
<Text fontStyle="italic">
|
||||
Posted in{" "}
|
||||
<Link as={RouterLink} to={`/c/${getCommunityName(community)}/${community.pubkey}`} color="blue.500">
|
||||
{getCommunityName(community)}
|
||||
</Link>{" "}
|
||||
community
|
||||
</Text>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody p="0">
|
||||
<NoteContentWithWarning event={event} />
|
||||
|
@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
|
||||
|
||||
import { getSharableEventAddress } from "../../helpers/nip19";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
|
||||
import { CustomMenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
|
||||
|
||||
import {
|
||||
ClipboardIcon,
|
||||
@ -27,12 +27,14 @@ 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";
|
||||
import { useMuteModalContext } from "../../providers/mute-modal-provider";
|
||||
|
||||
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 { openModal } = useMuteModalContext();
|
||||
|
||||
const { deleteEvent } = useDeleteEventContext();
|
||||
|
||||
@ -51,14 +53,18 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<CustomMenuIconButton {...props}>
|
||||
{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">
|
||||
<MenuItem
|
||||
onClick={isMuted ? unmute : () => openModal(event.pubkey)}
|
||||
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
|
||||
color="red.500"
|
||||
>
|
||||
{isMuted ? "Unmute User" : "Mute User"}
|
||||
</MenuItem>
|
||||
)}
|
||||
@ -84,7 +90,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
|
||||
Zaps/Reactions
|
||||
</MenuItem>
|
||||
</MenuIconButton>
|
||||
</CustomMenuIconButton>
|
||||
|
||||
{infoModal.isOpen && (
|
||||
<NoteDebugModal event={event} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
|
||||
|
30
src/components/post-modal/community-select.tsx
Normal file
30
src/components/post-modal/community-select.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Select, SelectProps } from "@chakra-ui/react";
|
||||
|
||||
import useSubscribedCommunitiesList from "../../hooks/use-subscribed-communities-list";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { getCommunityName } from "../../helpers/nostr/communities";
|
||||
import { AddressPointer } from "nostr-tools/lib/nip19";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import { getEventCoordinate } from "../../helpers/nostr/events";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
function CommunityOption({ pointer }: { pointer: AddressPointer }) {
|
||||
const community = useReplaceableEvent(pointer);
|
||||
if (!community) return;
|
||||
|
||||
return <option value={getEventCoordinate(community)}>{getCommunityName(community)}</option>;
|
||||
}
|
||||
|
||||
const CommunitySelect = forwardRef<HTMLSelectElement, Omit<SelectProps, "children">>(({ ...props }, ref) => {
|
||||
const account = useCurrentAccount();
|
||||
const { pointers } = useSubscribedCommunitiesList(account?.pubkey);
|
||||
|
||||
return (
|
||||
<Select placeholder="Select community" {...props} ref={ref}>
|
||||
{pointers.map((pointer) => (
|
||||
<CommunityOption key={pointer.identifier + pointer.pubkey} pointer={pointer} />
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
export default CommunitySelect;
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@ -13,6 +13,10 @@ import {
|
||||
Input,
|
||||
Switch,
|
||||
ModalProps,
|
||||
VisuallyHiddenInput,
|
||||
IconButton,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
} from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { useForm } from "react-hook-form";
|
||||
@ -21,14 +25,22 @@ import { Kind } from "nostr-tools";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useSigningContext } from "../../providers/signing-provider";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon } from "../icons";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon, ImageIcon } from "../icons";
|
||||
import { NoteContents } from "../note/note-contents";
|
||||
import { PublishDetails } from "../publish-details";
|
||||
import { TrustProvider } from "../../providers/trust";
|
||||
import { createEmojiTags, ensureNotifyPubkeys, finalizeNote, getContentMentions } from "../../helpers/nostr/post";
|
||||
import {
|
||||
correctContentMentions,
|
||||
createEmojiTags,
|
||||
ensureNotifyPubkeys,
|
||||
finalizeNote,
|
||||
getContentMentions,
|
||||
} from "../../helpers/nostr/post";
|
||||
import { UserAvatarStack } from "../compact-user-stack";
|
||||
import MagicTextArea from "../magic-textarea";
|
||||
import MagicTextArea, { RefType } from "../magic-textarea";
|
||||
import { useContextEmojis } from "../../providers/emoji-provider";
|
||||
import { nostrBuildUploadImage } from "../../helpers/nostr-build";
|
||||
import CommunitySelect from "./community-select";
|
||||
|
||||
export default function PostModal({
|
||||
isOpen,
|
||||
@ -47,35 +59,40 @@ export default function PostModal({
|
||||
content: initContent,
|
||||
nsfw: false,
|
||||
nsfwReason: "",
|
||||
community: "",
|
||||
},
|
||||
});
|
||||
watch("content");
|
||||
watch("nsfw");
|
||||
watch("nsfwReason");
|
||||
|
||||
// const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
// const [uploading, setUploading] = useState(false);
|
||||
// const uploadImage = async (imageFile: File) => {
|
||||
// try {
|
||||
// if (!imageFile.type.includes("image")) throw new Error("Only images are supported");
|
||||
// setUploading(true);
|
||||
// const payload = new FormData();
|
||||
// payload.append("fileToUpload", imageFile);
|
||||
// const response = await fetch("https://nostr.build/upload.php", { body: payload, method: "POST" }).then((res) =>
|
||||
// res.text(),
|
||||
// );
|
||||
// const imageUrl = response.match(/https:\/\/nostr\.build\/i\/[\w.]+/)?.[0];
|
||||
// if (imageUrl) {
|
||||
// setValue('content', getValues().content += imageUrl );
|
||||
// }
|
||||
// } catch (e) {
|
||||
// if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
// }
|
||||
// setUploading(false);
|
||||
// };
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadImage = useCallback(
|
||||
async (imageFile: File) => {
|
||||
try {
|
||||
if (!imageFile.type.includes("image")) throw new Error("Only images are supported");
|
||||
setUploading(true);
|
||||
|
||||
const response = await nostrBuildUploadImage(imageFile, requestSignature);
|
||||
const imageUrl = response.url;
|
||||
|
||||
const content = getValues().content;
|
||||
const position = textAreaRef.current?.getCaretPosition();
|
||||
if (position !== undefined) {
|
||||
setValue("content", content.slice(0, position) + imageUrl + content.slice(position));
|
||||
} else setValue("content", content + imageUrl);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
setUploading(false);
|
||||
},
|
||||
[setValue, getValues],
|
||||
);
|
||||
|
||||
const getDraft = useCallback(() => {
|
||||
const { content, nsfw, nsfwReason } = getValues();
|
||||
const { content, nsfw, nsfwReason, community } = getValues();
|
||||
|
||||
let updatedDraft = finalizeNote({
|
||||
content: content,
|
||||
@ -84,9 +101,14 @@ export default function PostModal({
|
||||
created_at: dayjs().unix(),
|
||||
});
|
||||
|
||||
updatedDraft.content = correctContentMentions(updatedDraft.content);
|
||||
|
||||
if (nsfw) {
|
||||
updatedDraft.tags.push(nsfwReason ? ["content-warning", nsfwReason] : ["content-warning"]);
|
||||
}
|
||||
if (community) {
|
||||
updatedDraft.tags.push(["a", community]);
|
||||
}
|
||||
|
||||
const contentMentions = getContentMentions(updatedDraft.content);
|
||||
updatedDraft = createEmojiTags(updatedDraft, emojis);
|
||||
@ -105,7 +127,7 @@ export default function PostModal({
|
||||
});
|
||||
|
||||
const canSubmit = getValues().content.length > 0;
|
||||
const mentions = getContentMentions(getValues().content);
|
||||
const mentions = getContentMentions(correctContentMentions(getValues().content));
|
||||
|
||||
const renderContent = () => {
|
||||
if (publishAction) {
|
||||
@ -127,10 +149,11 @@ export default function PostModal({
|
||||
onChange={(e) => setValue("content", e.target.value)}
|
||||
rows={5}
|
||||
isRequired
|
||||
// onPaste={(e) => {
|
||||
// const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
// if (imageFile) uploadImage(imageFile);
|
||||
// }}
|
||||
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||
onPaste={(e) => {
|
||||
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (imageFile) uploadImage(imageFile);
|
||||
}}
|
||||
/>
|
||||
{getValues().content.length > 0 && (
|
||||
<Box>
|
||||
@ -144,7 +167,7 @@ export default function PostModal({
|
||||
)}
|
||||
<Flex gap="2" alignItems="center" justifyContent="flex-end">
|
||||
<Flex mr="auto" gap="2">
|
||||
{/* <VisuallyHiddenInput
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={imageUploadRef}
|
||||
@ -159,7 +182,7 @@ export default function PostModal({
|
||||
title="Upload Image"
|
||||
onClick={() => imageUploadRef.current?.click()}
|
||||
isLoading={uploading}
|
||||
/> */}
|
||||
/>
|
||||
<Button
|
||||
variant="link"
|
||||
rightIcon={moreOptions.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}
|
||||
@ -182,6 +205,10 @@ export default function PostModal({
|
||||
</Flex>
|
||||
{moreOptions.isOpen && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Post to community</FormLabel>
|
||||
<CommunitySelect w="sm" {...register("community")} />
|
||||
</FormControl>
|
||||
<Flex gap="2" direction="column">
|
||||
<Switch {...register("nsfw")}>NSFW</Switch>
|
||||
{getValues().nsfw && <Input {...register("nsfwReason")} placeholder="Reason" maxW="50%" />}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ReactNode, memo } from "react";
|
||||
import { Text } from "@chakra-ui/react";
|
||||
import { ReactNode, memo, useEffect, useState } from "react";
|
||||
import { Box, Button, Text } from "@chakra-ui/react";
|
||||
import { Kind } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import useSubject from "../../../hooks/use-subject";
|
||||
import { TimelineLoader } from "../../../classes/timeline-loader";
|
||||
@ -11,9 +12,10 @@ import { STREAM_KIND } from "../../../helpers/nostr/stream";
|
||||
import StreamNote from "./stream-note";
|
||||
import { ErrorBoundary } from "../../error-boundary";
|
||||
import EmbeddedArticle from "../../embed-event/event-types/embedded-article";
|
||||
import { isReply } from "../../../helpers/nostr/events";
|
||||
import { getEventUID, isReply } from "../../../helpers/nostr/events";
|
||||
import ReplyNote from "./reply-note";
|
||||
import RelayRecommendation from "./relay-recommendation";
|
||||
import { ExtendedIntersectionObserverEntry, useIntersectionObserver } from "../../../providers/intersection-observer";
|
||||
|
||||
function RenderEvent({ event }: { event: NostrEvent }) {
|
||||
let content: ReactNode | null = null;
|
||||
@ -42,11 +44,77 @@ function RenderEvent({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
const RenderEventMemo = memo(RenderEvent);
|
||||
|
||||
const PRELOAD_NOTES = 5;
|
||||
function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) {
|
||||
const notes = useSubject(timeline.timeline);
|
||||
const notesArray = useSubject(timeline.timeline);
|
||||
const [latest, setLatest] = useState(() => dayjs().unix());
|
||||
const { subject } = useIntersectionObserver();
|
||||
|
||||
const [minDate, setMinDate] = useState(timeline.timeline.value[PRELOAD_NOTES]?.created_at ?? 0);
|
||||
|
||||
// reset the latest and minDate when timeline changes
|
||||
useEffect(() => {
|
||||
setLatest(dayjs().unix());
|
||||
setMinDate(timeline.timeline.value[PRELOAD_NOTES]?.created_at ?? 0);
|
||||
}, [timeline, setMinDate, setLatest]);
|
||||
|
||||
const newNotes: NostrEvent[] = [];
|
||||
const notes: NostrEvent[] = [];
|
||||
for (const note of notesArray) {
|
||||
if (note.created_at > latest) newNotes.push(note);
|
||||
else if (note.created_at > minDate) notes.push(note);
|
||||
}
|
||||
|
||||
const [intersectionEntryCache] = useState(() => new Map<string, IntersectionObserverEntry>());
|
||||
useEffect(() => {
|
||||
const listener = (entities: ExtendedIntersectionObserverEntry[]) => {
|
||||
for (const entity of entities) entity.id && intersectionEntryCache.set(entity.id, entity.entry);
|
||||
|
||||
let min: number = Infinity;
|
||||
let preload = PRELOAD_NOTES;
|
||||
let foundVisible = false;
|
||||
for (const event of timeline.timeline.value) {
|
||||
if (event.created_at > latest) continue;
|
||||
const entry = intersectionEntryCache.get(getEventUID(event));
|
||||
if (!entry || !entry.isIntersecting) {
|
||||
if (foundVisible) {
|
||||
// found and event below the view
|
||||
if (preload-- < 0) break;
|
||||
if (event.created_at < min) min = event.created_at;
|
||||
} else {
|
||||
// found and event above the view
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// found visible event
|
||||
foundVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
setMinDate((v) => Math.min(v, min));
|
||||
};
|
||||
|
||||
subject.subscribe(listener);
|
||||
return () => {
|
||||
subject.unsubscribe(listener);
|
||||
};
|
||||
}, [setMinDate, intersectionEntryCache, latest, timeline]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newNotes.length > 0 && (
|
||||
<Box h="0" overflow="visible" w="full" zIndex={100} display="flex" position="relative">
|
||||
<Button
|
||||
onClick={() => setLatest(timeline.timeline.value[0].created_at + 10)}
|
||||
colorScheme="brand"
|
||||
size="lg"
|
||||
mx="auto"
|
||||
w={["50%", null, "30%"]}
|
||||
>
|
||||
Show {newNotes.length} new notes
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{notes.map((note) => (
|
||||
<RenderEventMemo key={note.id} event={note} />
|
||||
))}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import { Flex, Heading, SkeletonText, Text } from "@chakra-ui/react";
|
||||
import { validateEvent } from "nostr-tools";
|
||||
import { Kind, validateEvent } from "nostr-tools";
|
||||
|
||||
import { isETag, NostrEvent } from "../../../types/nostr-event";
|
||||
import { Note } from "../../note";
|
||||
@ -13,6 +13,8 @@ import { safeJson } from "../../../helpers/parse";
|
||||
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
import useSingleEvent from "../../../hooks/use-single-event";
|
||||
import { EmbedEvent } from "../../embed-event";
|
||||
import useUserMuteFilter from "../../../hooks/use-user-mute-filter";
|
||||
|
||||
function parseHardcodedNoteContent(event: NostrEvent) {
|
||||
const json = safeJson(event.content, null);
|
||||
@ -30,15 +32,17 @@ export default function RepostNote({ event }: { event: NostrEvent }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
const muteFilter = useUserMuteFilter();
|
||||
const hardCodedNote = parseHardcodedNoteContent(event);
|
||||
|
||||
const [_, eventId, relay] = event.tags.find(isETag) ?? [];
|
||||
const readRelays = useReadRelayUrls(relay ? [relay] : []);
|
||||
|
||||
const loadedNote = useSingleEvent(eventId, readRelays);
|
||||
|
||||
const note = hardCodedNote || loadedNote;
|
||||
|
||||
if (note && muteFilter(note)) return;
|
||||
|
||||
return (
|
||||
<TrustProvider event={event}>
|
||||
<Flex gap="2" direction="column" ref={ref}>
|
||||
@ -53,7 +57,14 @@ export default function RepostNote({ event }: { event: NostrEvent }) {
|
||||
</Text>
|
||||
<NoteMenu event={event} size="sm" variant="link" aria-label="note options" />
|
||||
</Flex>
|
||||
{!note ? <SkeletonText /> : <Note event={note} showReplyButton />}
|
||||
{!note ? (
|
||||
<SkeletonText />
|
||||
) : note.kind === Kind.Text ? (
|
||||
// NOTE: tell the note not to register itself with the intersection observer. since this is an older note it will break the order of the timeline
|
||||
<Note event={note} showReplyButton registerIntersectionEntity={false} />
|
||||
) : (
|
||||
<EmbedEvent event={note} />
|
||||
)}
|
||||
</Flex>
|
||||
</TrustProvider>
|
||||
);
|
||||
|
@ -52,7 +52,7 @@ export default function TimelinePage({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<IntersectionObserverProvider<string> callback={callback}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex direction="column" gap="2" {...props}>
|
||||
{header}
|
||||
{renderTimeline()}
|
||||
|
@ -13,6 +13,7 @@ import { useRegisterIntersectionEntity } from "../../../providers/intersection-o
|
||||
import { Photo } from "react-photo-album";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { getEventUID } from "../../../helpers/nostr/events";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
function GalleryImage({ event, ...props }: EmbeddedImageProps & { event: NostrEvent }) {
|
||||
const ref = useRef<HTMLImageElement | null>(null);
|
||||
@ -42,6 +43,7 @@ export default function MediaTimeline({ timeline }: { timeline: TimelineLoader }
|
||||
var images: PhotoWithEvent[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
if (event.kind === Kind.Repost) continue;
|
||||
const urls = event.content.matchAll(getMatchLink());
|
||||
|
||||
let i = 0;
|
||||
|
@ -34,6 +34,7 @@ import replaceableEventLoaderService from "../services/replaceable-event-request
|
||||
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";
|
||||
import { useMuteModalContext } from "../providers/mute-modal-provider";
|
||||
|
||||
function UsersLists({ pubkey }: { pubkey: string }) {
|
||||
const toast = useToast();
|
||||
@ -117,7 +118,8 @@ export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButt
|
||||
const account = useCurrentAccount()!;
|
||||
const { requestSignature } = useSigningContext();
|
||||
const contacts = useUserContactList(account?.pubkey, [], { ignoreCache: true });
|
||||
const { isMuted, mute, unmute } = useUserMuteFunctions(pubkey);
|
||||
const { isMuted, unmute } = useUserMuteFunctions(pubkey);
|
||||
const { openModal } = useMuteModalContext();
|
||||
|
||||
const isFollowing = isPubkeyInList(contacts, pubkey);
|
||||
const isDisabled = account?.readonly ?? true;
|
||||
@ -153,7 +155,7 @@ export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButt
|
||||
)}
|
||||
{account?.pubkey !== pubkey && (
|
||||
<MenuItem
|
||||
onClick={isMuted ? unmute : mute}
|
||||
onClick={isMuted ? unmute : () => openModal(pubkey)}
|
||||
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
|
||||
color="red.500"
|
||||
isDisabled={isDisabled}
|
||||
|
54
src/helpers/nostr-build.ts
Normal file
54
src/helpers/nostr-build.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { nip98 } from "nostr-tools";
|
||||
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
|
||||
|
||||
type NostrBuildResponse = {
|
||||
status: "success" | "error";
|
||||
message: string;
|
||||
data: [
|
||||
{
|
||||
input_name: "APIv2";
|
||||
name: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
responsive: {
|
||||
"240p": string;
|
||||
"360p": string;
|
||||
"480p": string;
|
||||
"720p": string;
|
||||
"1080p": string;
|
||||
};
|
||||
blurhash: string;
|
||||
sha256: string;
|
||||
type: "picture" | "video";
|
||||
mime: string;
|
||||
size: number;
|
||||
metadata: Record<string, string>;
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export async function nostrBuildUploadImage(image: File, sign?: (draft: DraftNostrEvent) => Promise<NostrEvent>) {
|
||||
if (!image.type.includes("image")) throw new Error("Only images are supported");
|
||||
|
||||
const url = "https://nostr.build/api/v2/upload/files";
|
||||
|
||||
const payload = new FormData();
|
||||
payload.append("fileToUpload", image);
|
||||
|
||||
const headers: HeadersInit = {};
|
||||
if (sign) {
|
||||
// @ts-ignore
|
||||
const token = await nip98.getToken(url, "post", sign, true);
|
||||
headers.Authorization = token;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { body: payload, method: "POST", headers }).then(
|
||||
(res) => res.json() as Promise<NostrBuildResponse>,
|
||||
);
|
||||
|
||||
return response.data[0];
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { validateEvent } from "nostr-tools";
|
||||
import { NostrEvent, isDTag, isPTag } from "../../types/nostr-event";
|
||||
|
||||
export const SUBSCRIBED_COMMUNITIES_LIST_IDENTIFIER = "communities";
|
||||
@ -25,6 +26,16 @@ export function getCommunityDescription(community: NostrEvent) {
|
||||
return community.tags.find((t) => t[0] === "description")?.[1];
|
||||
}
|
||||
|
||||
export function getApprovedEmbeddedNote(approval: NostrEvent) {
|
||||
if (!approval.content) return null;
|
||||
try {
|
||||
const json = JSON.parse(approval.content);
|
||||
validateEvent(json);
|
||||
return (json as NostrEvent) ?? null;
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCommunity(community: NostrEvent) {
|
||||
try {
|
||||
getCommunityName(community);
|
||||
|
@ -153,11 +153,20 @@ export function getEventCoordinate(event: NostrEvent) {
|
||||
const d = event.tags.find((t) => t[0] === "d")?.[1];
|
||||
return d ? `${event.kind}:${event.pubkey}:${d}` : `${event.kind}:${event.pubkey}`;
|
||||
}
|
||||
export function pointerToATag(pointer: AddressPointer): ATag {
|
||||
const relay = pointer.relays?.[0];
|
||||
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`;
|
||||
return relay ? ["a", coordinate, relay] : ["a", coordinate];
|
||||
}
|
||||
|
||||
export type CustomEventPointer = Omit<AddressPointer, "identifier"> & {
|
||||
identifier?: string;
|
||||
};
|
||||
export function parseCoordinate(a: string): CustomEventPointer | null {
|
||||
|
||||
export function parseCoordinate(a: string): CustomEventPointer | null;
|
||||
export function parseCoordinate(a: string, requireD: false): CustomEventPointer | null;
|
||||
export function parseCoordinate(a: string, requireD: true): AddressPointer;
|
||||
export function parseCoordinate(a: string, requireD = false): CustomEventPointer | null {
|
||||
const parts = a.split(":") as (string | undefined)[];
|
||||
const kind = parts[0] && parseInt(parts[0]);
|
||||
const pubkey = parts[1];
|
||||
@ -165,6 +174,7 @@ export function parseCoordinate(a: string): CustomEventPointer | null {
|
||||
|
||||
if (!kind) return null;
|
||||
if (!pubkey) return null;
|
||||
if (requireD && !d) return null;
|
||||
|
||||
return {
|
||||
kind,
|
||||
|
@ -2,7 +2,7 @@ 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 { DraftNostrEvent, NostrEvent, PTag, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event";
|
||||
import { parseCoordinate } from "./events";
|
||||
|
||||
export const PEOPLE_LIST_KIND = 30000;
|
||||
@ -31,19 +31,28 @@ export function isSpecialListKind(kind: number) {
|
||||
return kind === Kind.Contacts || kind === PIN_LIST_KIND || kind === MUTE_LIST_KIND;
|
||||
}
|
||||
|
||||
export function getPubkeysFromList(event: NostrEvent) {
|
||||
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2] }));
|
||||
export function cloneList(list: NostrEvent, keepCreatedAt = false): DraftNostrEvent {
|
||||
return {
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: Array.from(list.tags),
|
||||
created_at: keepCreatedAt ? list.created_at : dayjs().unix(),
|
||||
};
|
||||
}
|
||||
export function getEventsFromList(event: NostrEvent) {
|
||||
|
||||
export function getPubkeysFromList(event: NostrEvent | DraftNostrEvent) {
|
||||
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2], petname: t[3] }));
|
||||
}
|
||||
export function getEventsFromList(event: NostrEvent | DraftNostrEvent) {
|
||||
return event.tags.filter(isETag).map((t) => ({ id: t[1], relay: t[2] }));
|
||||
}
|
||||
export function getReferencesFromList(event: NostrEvent) {
|
||||
export function getReferencesFromList(event: NostrEvent | DraftNostrEvent) {
|
||||
return event.tags.filter(isRTag).map((t) => ({ url: t[1], petname: t[2] }));
|
||||
}
|
||||
export function getCoordinatesFromList(event: NostrEvent) {
|
||||
export function getCoordinatesFromList(event: NostrEvent | DraftNostrEvent) {
|
||||
return event.tags.filter(isATag).map((t) => ({ coordinate: t[1], relay: t[2] }));
|
||||
}
|
||||
export function getParsedCordsFromList(event: NostrEvent) {
|
||||
export function getParsedCordsFromList(event: NostrEvent | DraftNostrEvent) {
|
||||
const pointers: AddressPointer[] = [];
|
||||
|
||||
for (const tag of event.tags) {
|
||||
@ -58,9 +67,9 @@ export function getParsedCordsFromList(event: NostrEvent) {
|
||||
return pointers;
|
||||
}
|
||||
|
||||
export function isPubkeyInList(event?: NostrEvent, pubkey?: string) {
|
||||
if (!pubkey || !event) return false;
|
||||
return event.tags.some((t) => t[0] === "p" && t[1] === pubkey);
|
||||
export function isPubkeyInList(list?: NostrEvent, pubkey?: string) {
|
||||
if (!pubkey || !list) return false;
|
||||
return list.tags.some((t) => t[0] === "p" && t[1] === pubkey);
|
||||
}
|
||||
|
||||
export function createEmptyContactList(): DraftNostrEvent {
|
||||
@ -71,22 +80,22 @@ export function createEmptyContactList(): DraftNostrEvent {
|
||||
kind: Kind.Contacts,
|
||||
};
|
||||
}
|
||||
export function createEmptyMuteList(): DraftNostrEvent {
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
content: "",
|
||||
tags: [],
|
||||
kind: MUTE_LIST_KIND,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAddPerson(list: NostrEvent | DraftNostrEvent, pubkey: string, relay?: string): DraftNostrEvent {
|
||||
export function listAddPerson(
|
||||
list: NostrEvent | DraftNostrEvent,
|
||||
pubkey: string,
|
||||
relay?: string,
|
||||
petname?: string,
|
||||
): DraftNostrEvent {
|
||||
if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list");
|
||||
const pTag: PTag = ["p", pubkey, relay ?? "", petname ?? ""];
|
||||
while (pTag[pTag.length - 1] === "") pTag.pop();
|
||||
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
kind: list.kind,
|
||||
content: list.content,
|
||||
tags: [...list.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]],
|
||||
tags: [...list.tags, pTag],
|
||||
};
|
||||
}
|
||||
|
||||
|
86
src/helpers/nostr/mute-list.ts
Normal file
86
src/helpers/nostr/mute-list.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import dayjs from "dayjs";
|
||||
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
|
||||
import { MUTE_LIST_KIND, getPubkeysFromList, isPubkeyInList, listAddPerson, listRemovePerson } from "./lists";
|
||||
|
||||
export function getPubkeysFromMuteList(muteList: NostrEvent | DraftNostrEvent) {
|
||||
const expirations = getPubkeysExpiration(muteList);
|
||||
return getPubkeysFromList(muteList).map((p) => ({
|
||||
pubkey: p.pubkey,
|
||||
expiration: expirations[p.pubkey] ?? Infinity,
|
||||
}));
|
||||
}
|
||||
export function getPubkeysExpiration(muteList: NostrEvent | DraftNostrEvent) {
|
||||
return muteList.tags.reduce<Record<string, number>>((dir, tag) => {
|
||||
if (tag[0] === "mute_expiration" && tag[1] && tag[2]) {
|
||||
const date = parseInt(tag[2]);
|
||||
if (dayjs.unix(date).isValid()) {
|
||||
return { ...dir, [tag[1]]: date };
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}, {});
|
||||
}
|
||||
export function getPubkeyExpiration(muteList: NostrEvent, pubkey: string) {
|
||||
const tag = muteList.tags.find((tag) => {
|
||||
return tag[0] === "mute_expiration" && tag[1] === pubkey && tag[2];
|
||||
}, {});
|
||||
|
||||
if (tag && tag[1] && tag[2]) {
|
||||
const date = parseInt(tag[2]);
|
||||
if (dayjs.unix(date).isValid()) return date;
|
||||
}
|
||||
return isPubkeyInList(muteList, pubkey) ? Infinity : 0;
|
||||
}
|
||||
|
||||
export function createEmptyMuteList(): DraftNostrEvent {
|
||||
return {
|
||||
created_at: dayjs().unix(),
|
||||
content: "",
|
||||
tags: [],
|
||||
kind: MUTE_LIST_KIND,
|
||||
};
|
||||
}
|
||||
|
||||
export function muteListAddPubkey(muteList: NostrEvent | DraftNostrEvent, pubkey: string, expiration = Infinity) {
|
||||
let draft = listAddPerson(muteList, pubkey);
|
||||
if (expiration < Infinity) {
|
||||
draft = {
|
||||
...draft,
|
||||
tags: [...draft.tags, ["mute_expiration", pubkey, String(expiration)]],
|
||||
};
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
export function muteListRemovePubkey(muteList: NostrEvent | DraftNostrEvent, pubkey: string) {
|
||||
let draft = listRemovePerson(muteList, pubkey);
|
||||
|
||||
draft = {
|
||||
...draft,
|
||||
tags: draft.tags.filter((t) => {
|
||||
if (t[0] === "mute_expiration" && t[1] === pubkey) return false;
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function pruneExpiredPubkeys(muteList: NostrEvent | DraftNostrEvent) {
|
||||
const expirations = getPubkeysExpiration(muteList);
|
||||
const now = dayjs().unix();
|
||||
const draft: DraftNostrEvent = {
|
||||
kind: MUTE_LIST_KIND,
|
||||
content: muteList.content,
|
||||
created_at: now,
|
||||
tags: muteList.tags.filter((tag) => {
|
||||
// remove expired "expiration" tags
|
||||
if (tag[0] === "mute_expiration" && parseInt(tag[2]) < now) return false;
|
||||
// remove expired "p" tags
|
||||
if (isPTag(tag) && expirations[tag[1]] < now) return false;
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { DraftNostrEvent, NostrEvent, PTag, Tag } from "../../types/nostr-event";
|
||||
import { DraftNostrEvent, NostrEvent, Tag } from "../../types/nostr-event";
|
||||
import { getMatchEmoji, getMatchHashtag } from "../regexp";
|
||||
import { normalizeToHex } from "../nip19";
|
||||
import { getReferences } from "./events";
|
||||
import { getEventRelays } from "../../services/event-relays";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
@ -62,6 +61,10 @@ export function ensureNotifyPubkeys(draft: DraftNostrEvent, pubkeys: string[]) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function correctContentMentions(content: string) {
|
||||
return content.replace(/(\s|^)(?:@)?(npub1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})/gi, "$1nostr:$2");
|
||||
}
|
||||
|
||||
export function getContentMentions(content: string) {
|
||||
const matched = content.matchAll(/nostr:(npub1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})/gi);
|
||||
return Array.from(matched)
|
||||
@ -108,7 +111,8 @@ export function createEmojiTags(draft: DraftNostrEvent, emojis: Emoji[]) {
|
||||
}
|
||||
|
||||
export function finalizeNote(draft: DraftNostrEvent) {
|
||||
let updated = draft;
|
||||
let updated: DraftNostrEvent = { ...draft, tags: Array.from(draft.tags) };
|
||||
updated.content = correctContentMentions(updated.content);
|
||||
updated = createHashtagTags(updated);
|
||||
return updated;
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { NostrEvent } from "../types/nostr-event";
|
||||
import { EventReferences, getReferences } from "./nostr/events";
|
||||
|
||||
export function countReplies(thread: ThreadItem): number {
|
||||
return thread.replies.reduce((c, item) => c + countReplies(item), 0) + thread.replies.length;
|
||||
export function countReplies(replies: ThreadItem[]): number {
|
||||
return replies.reduce((c, item) => c + countReplies(item.replies), 0) + replies.length;
|
||||
}
|
||||
|
||||
export type ThreadItem = {
|
||||
|
@ -3,6 +3,7 @@ import { NostrEvent } from "../types/nostr-event";
|
||||
import { truncatedId } from "./nostr/events";
|
||||
|
||||
export type Kind0ParsedContent = {
|
||||
pubkey?: string,
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
@ -18,6 +19,7 @@ export function parseKind0Event(event: NostrEvent): Kind0ParsedContent {
|
||||
if (event.kind !== 0) throw new Error("expected a kind 0 event");
|
||||
try {
|
||||
const metadata = JSON.parse(event.content) as Kind0ParsedContent;
|
||||
metadata.pubkey = event.pubkey
|
||||
|
||||
// ensure nip05 is a string
|
||||
if (metadata.nip05 && typeof metadata.nip05 !== "string") metadata.nip05 = String(metadata.nip05);
|
||||
|
@ -10,7 +10,7 @@ export function useTimelineCurserIntersectionCallback(timeline: TimelineLoader)
|
||||
timeline.loadNextBlocks();
|
||||
}, 1000);
|
||||
|
||||
return useIntersectionMapCallback<string>(
|
||||
return useIntersectionMapCallback(
|
||||
(map) => {
|
||||
// find oldest event that is visible
|
||||
let oldestEvent: NostrEvent | undefined = undefined;
|
||||
|
@ -5,10 +5,11 @@ 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";
|
||||
import { RequestOptions } from "../services/replaceable-event-requester";
|
||||
|
||||
export default function useUserMuteFilter(pubkey?: string) {
|
||||
export default function useUserMuteFilter(pubkey?: string, additionalRelays?: string[], opts?: RequestOptions) {
|
||||
const account = useCurrentAccount();
|
||||
const muteList = useUserMuteList(pubkey || account?.pubkey, [], { ignoreCache: true });
|
||||
const muteList = useUserMuteList(pubkey || account?.pubkey, additionalRelays, { ignoreCache: true, ...opts });
|
||||
const pubkeys = useMemo(() => (muteList ? getPubkeysFromList(muteList).map((p) => p.pubkey) : []), [muteList]);
|
||||
|
||||
return useCallback(
|
||||
|
@ -1,5 +1,12 @@
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
import { createEmptyMuteList, listAddPerson, listRemovePerson, isPubkeyInList } from "../helpers/nostr/lists";
|
||||
import { isPubkeyInList } from "../helpers/nostr/lists";
|
||||
import {
|
||||
createEmptyMuteList,
|
||||
getPubkeyExpiration,
|
||||
muteListAddPubkey,
|
||||
muteListRemovePubkey,
|
||||
pruneExpiredPubkeys,
|
||||
} from "../helpers/nostr/mute-list";
|
||||
import { useSigningContext } from "../providers/signing-provider";
|
||||
import clientRelaysService from "../services/client-relays";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
@ -13,19 +20,24 @@ export default function useUserMuteFunctions(pubkey: string) {
|
||||
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
|
||||
|
||||
const isMuted = isPubkeyInList(muteList, pubkey);
|
||||
const expiration = muteList ? getPubkeyExpiration(muteList, pubkey) : 0;
|
||||
|
||||
const mute = useAsyncErrorHandler(async () => {
|
||||
const draft = listAddPerson(muteList || createEmptyMuteList(), pubkey);
|
||||
let draft = muteListAddPubkey(muteList || createEmptyMuteList(), pubkey);
|
||||
draft = pruneExpiredPubkeys(draft);
|
||||
|
||||
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);
|
||||
let draft = muteListRemovePubkey(muteList || createEmptyMuteList(), pubkey);
|
||||
draft = pruneExpiredPubkeys(draft);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
new NostrPublishAction("Unmute", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
}, [requestSignature, muteList]);
|
||||
|
||||
return { isMuted, mute, unmute };
|
||||
return { isMuted, expiration, mute, unmute };
|
||||
}
|
||||
|
@ -1,10 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import { getPubkeysFromList } from "../helpers/nostr/lists";
|
||||
import useUserContactList from "./use-user-contact-list";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import { useReadRelayUrls } from "./use-client-relays";
|
||||
import useSubjects from "./use-subjects";
|
||||
import userMetadataService from "../services/user-metadata";
|
||||
import { Kind0ParsedContent } from "../helpers/user-metadata";
|
||||
|
||||
export function useUsersMetadata(pubkeys: string[], additionalRelays: string[] = []) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
const metadataSubjects = useMemo(() => {
|
||||
return pubkeys.map((pubkey) => userMetadataService.requestMetadata(pubkey, readRelays));
|
||||
}, [pubkeys]);
|
||||
const metadataArray = useSubjects(metadataSubjects);
|
||||
const metadataDir = useMemo(() => {
|
||||
const dir: Record<string, Kind0ParsedContent> = {};
|
||||
for (const metadata of metadataArray) {
|
||||
if (!metadata.pubkey) continue;
|
||||
dir[metadata.pubkey] = metadata;
|
||||
}
|
||||
return dir;
|
||||
}, [metadataArray]);
|
||||
|
||||
return metadataDir;
|
||||
}
|
||||
|
||||
export default function useUserNetwork(pubkey: string, additionalRelays: string[] = []) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
@ -18,6 +39,14 @@ export default function useUserNetwork(pubkey: string, additionalRelays: string[
|
||||
}, [contactsPubkeys, readRelays.join("|")]);
|
||||
|
||||
const lists = useSubjects(subjects);
|
||||
const metadata = useUsersMetadata(lists.map((list) => list.pubkey).concat(pubkey));
|
||||
|
||||
return { lists, contacts, metadata };
|
||||
}
|
||||
|
||||
export function useNetworkConnectionCount(pubkey: string, additionalRelays: string[] = []) {
|
||||
const { lists, contacts } = useUserNetwork(pubkey, additionalRelays);
|
||||
const contactsPubkeys = contacts ? getPubkeysFromList(contacts) : [];
|
||||
|
||||
return useMemo(() => {
|
||||
const pubkeys = new Map<string, number>();
|
||||
|
@ -104,6 +104,10 @@ export default function DrawerSubViewProvider({
|
||||
openInParent(e.location);
|
||||
}
|
||||
});
|
||||
|
||||
// use the parent routers createHref method so that users can open links in new tabs
|
||||
newRouter.createHref = parentRouter.createHref;
|
||||
|
||||
setRouter(newRouter);
|
||||
},
|
||||
[setRouter, openInParent],
|
||||
|
@ -10,6 +10,7 @@ import NotificationTimelineProvider from "./notification-timeline";
|
||||
import PostModalProvider from "./post-modal-provider";
|
||||
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
|
||||
import { UserContactsUserDirectoryProvider } from "./user-directory-provider";
|
||||
import MuteModalProvider from "./mute-modal-provider";
|
||||
|
||||
// Top level providers, should be render as close to the root as possible
|
||||
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
@ -31,17 +32,19 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SigningProvider>
|
||||
<DeleteEventProvider>
|
||||
<InvoiceModalProvider>
|
||||
<NotificationTimelineProvider>
|
||||
<DefaultEmojiProvider>
|
||||
<UserEmojiProvider>
|
||||
<UserContactsUserDirectoryProvider>
|
||||
<PostModalProvider>{children}</PostModalProvider>
|
||||
</UserContactsUserDirectoryProvider>
|
||||
</UserEmojiProvider>
|
||||
</DefaultEmojiProvider>
|
||||
</NotificationTimelineProvider>
|
||||
</InvoiceModalProvider>
|
||||
<MuteModalProvider>
|
||||
<InvoiceModalProvider>
|
||||
<NotificationTimelineProvider>
|
||||
<DefaultEmojiProvider>
|
||||
<UserEmojiProvider>
|
||||
<UserContactsUserDirectoryProvider>
|
||||
<PostModalProvider>{children}</PostModalProvider>
|
||||
</UserContactsUserDirectoryProvider>
|
||||
</UserEmojiProvider>
|
||||
</DefaultEmojiProvider>
|
||||
</NotificationTimelineProvider>
|
||||
</InvoiceModalProvider>
|
||||
</MuteModalProvider>
|
||||
</DeleteEventProvider>
|
||||
</SigningProvider>
|
||||
);
|
||||
|
@ -11,22 +11,26 @@ import {
|
||||
} from "react";
|
||||
import { useMount, useUnmount } from "react-use";
|
||||
|
||||
import Subject from "../classes/subject";
|
||||
|
||||
export type ExtendedIntersectionObserverEntry = { entry: IntersectionObserverEntry; id: string | undefined };
|
||||
export type ExtendedIntersectionObserverCallback = (
|
||||
entries: ExtendedIntersectionObserverEntry[],
|
||||
observer: IntersectionObserver,
|
||||
) => void;
|
||||
|
||||
const IntersectionObserverContext = createContext<{
|
||||
observer?: IntersectionObserver;
|
||||
setElementId: (element: Element, id: any) => void;
|
||||
}>({ setElementId: () => {} });
|
||||
|
||||
export type ExtendedIntersectionObserverEntry<T> = { entry: IntersectionObserverEntry; id: T | undefined };
|
||||
export type ExtendedIntersectionObserverCallback<T> = (
|
||||
entries: ExtendedIntersectionObserverEntry<T>[],
|
||||
observer: IntersectionObserver,
|
||||
) => void;
|
||||
// NOTE: hard codded string type
|
||||
subject: Subject<ExtendedIntersectionObserverEntry[]>;
|
||||
}>({ setElementId: () => {}, subject: new Subject() });
|
||||
|
||||
export function useIntersectionObserver() {
|
||||
return useContext(IntersectionObserverContext);
|
||||
}
|
||||
|
||||
export function useRegisterIntersectionEntity<T>(ref: MutableRefObject<Element | null>, id?: T) {
|
||||
export function useRegisterIntersectionEntity(ref: MutableRefObject<Element | null>, id?: string) {
|
||||
const { observer, setElementId } = useIntersectionObserver();
|
||||
|
||||
useEffect(() => {
|
||||
@ -40,24 +44,22 @@ export function useRegisterIntersectionEntity<T>(ref: MutableRefObject<Element |
|
||||
});
|
||||
}
|
||||
|
||||
export function useIntersectionMapCallback<T>(
|
||||
callback: (map: Map<T, IntersectionObserverEntry>) => void,
|
||||
/** @deprecated */
|
||||
export function useIntersectionMapCallback(
|
||||
callback: (map: Map<string, IntersectionObserverEntry>) => void,
|
||||
watch: DependencyList,
|
||||
) {
|
||||
const map = useMemo(() => new Map<T, IntersectionObserverEntry>(), []);
|
||||
return useCallback<ExtendedIntersectionObserverCallback<T>>(
|
||||
const map = useMemo(() => new Map<string, IntersectionObserverEntry>(), []);
|
||||
return useCallback<ExtendedIntersectionObserverCallback>(
|
||||
(entries) => {
|
||||
for (const { id, entry } of entries) {
|
||||
if (id) map.set(id, entry);
|
||||
}
|
||||
|
||||
for (const { id, entry } of entries) id && map.set(id, entry);
|
||||
callback(map);
|
||||
},
|
||||
[callback, ...watch],
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntersectionObserverProvider<T = undefined>({
|
||||
export default function IntersectionObserverProvider({
|
||||
children,
|
||||
root,
|
||||
rootMargin,
|
||||
@ -67,18 +69,23 @@ export default function IntersectionObserverProvider<T = undefined>({
|
||||
root?: MutableRefObject<HTMLElement | null>;
|
||||
rootMargin?: IntersectionObserverInit["rootMargin"];
|
||||
threshold?: IntersectionObserverInit["threshold"];
|
||||
callback: ExtendedIntersectionObserverCallback<T>;
|
||||
callback: ExtendedIntersectionObserverCallback;
|
||||
}) {
|
||||
const elementIds = useMemo(() => new WeakMap<Element, T>(), []);
|
||||
const elementIds = useMemo(() => new WeakMap<Element, string>(), []);
|
||||
const [subject] = useState(() => new Subject<ExtendedIntersectionObserverEntry[]>([], false));
|
||||
|
||||
const handleIntersection = useCallback<IntersectionObserverCallback>((entries, observer) => {
|
||||
callback(
|
||||
entries.map((entry) => {
|
||||
const handleIntersection = useCallback<IntersectionObserverCallback>(
|
||||
(entries, observer) => {
|
||||
const extendedEntries = entries.map((entry) => {
|
||||
return { entry, id: elementIds.get(entry.target) };
|
||||
}),
|
||||
observer,
|
||||
);
|
||||
}, []);
|
||||
});
|
||||
callback(extendedEntries, observer);
|
||||
|
||||
subject.next(extendedEntries);
|
||||
},
|
||||
[subject],
|
||||
);
|
||||
|
||||
const [observer, setObserver] = useState<IntersectionObserver>(
|
||||
() => new IntersectionObserver(handleIntersection, { rootMargin, threshold }),
|
||||
);
|
||||
@ -94,7 +101,7 @@ export default function IntersectionObserverProvider<T = undefined>({
|
||||
});
|
||||
|
||||
const setElementId = useCallback(
|
||||
(element: Element, id: T) => {
|
||||
(element: Element, id: string) => {
|
||||
elementIds.set(element, id);
|
||||
},
|
||||
[elementIds],
|
||||
@ -104,8 +111,9 @@ export default function IntersectionObserverProvider<T = undefined>({
|
||||
() => ({
|
||||
observer,
|
||||
setElementId,
|
||||
subject,
|
||||
}),
|
||||
[observer, setElementId],
|
||||
[observer, setElementId, subject],
|
||||
);
|
||||
|
||||
return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>;
|
||||
|
206
src/providers/mute-modal-provider.tsx
Normal file
206
src/providers/mute-modal-provider.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
ModalProps,
|
||||
SimpleGrid,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { getUserDisplayName } from "../helpers/user-metadata";
|
||||
import { useUserMetadata } from "../hooks/use-user-metadata";
|
||||
import { useCurrentAccount } from "../hooks/use-current-account";
|
||||
import {
|
||||
createEmptyMuteList,
|
||||
getPubkeysExpiration,
|
||||
muteListAddPubkey,
|
||||
pruneExpiredPubkeys,
|
||||
} from "../helpers/nostr/mute-list";
|
||||
import { cloneList } from "../helpers/nostr/lists";
|
||||
import { useSigningContext } from "./signing-provider";
|
||||
import NostrPublishAction from "../classes/nostr-publish-action";
|
||||
import clientRelaysService from "../services/client-relays";
|
||||
import replaceableEventLoaderService from "../services/replaceable-event-requester";
|
||||
import useUserMuteList from "../hooks/use-user-mute-list";
|
||||
import { useInterval } from "react-use";
|
||||
import { DraftNostrEvent } from "../types/nostr-event";
|
||||
import { UserAvatar } from "../components/user-avatar";
|
||||
import { UserLink } from "../components/user-link";
|
||||
|
||||
type MuteModalContextType = {
|
||||
openModal: (pubkey: string) => void;
|
||||
};
|
||||
|
||||
const MuteModalContext = createContext<MuteModalContextType>({
|
||||
openModal: () => {},
|
||||
});
|
||||
|
||||
export function useMuteModalContext() {
|
||||
return useContext(MuteModalContext);
|
||||
}
|
||||
|
||||
function MuteModal({ pubkey, onClose, ...props }: Omit<ModalProps, "children"> & { pubkey: string }) {
|
||||
const metadata = useUserMetadata(pubkey);
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
|
||||
const account = useCurrentAccount();
|
||||
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
|
||||
const handleClick = async (expiration: number) => {
|
||||
try {
|
||||
// mute user
|
||||
let draft = muteList ? cloneList(muteList) : createEmptyMuteList();
|
||||
draft = pruneExpiredPubkeys(draft);
|
||||
draft = muteListAddPubkey(draft, pubkey, expiration);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
new NostrPublishAction("Mute", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} size="lg" {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">Mute {getUserDisplayName(metadata, pubkey)} for:</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody px="4" py="0">
|
||||
<SimpleGrid columns={3} spacing="2">
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "minute").unix())}>
|
||||
1 Minute
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(5, "minutes").unix())}>
|
||||
5 Minutes
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(30, "minutes").unix())}>
|
||||
30 Minutes
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "hour").unix())}>
|
||||
1 Hour
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(5, "hours").unix())}>
|
||||
5 Hours
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "day").unix())}>
|
||||
1 Day
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(3, "days").unix())}>
|
||||
3 Days
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "week").unix())}>
|
||||
1 Week
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleClick(dayjs().add(2, "weeks").unix())}>
|
||||
2 Weeks
|
||||
</Button>
|
||||
</SimpleGrid>
|
||||
<Button variant="outline" onClick={() => handleClick(Infinity)} w="full" mt="2">
|
||||
Forever
|
||||
</Button>
|
||||
</ModalBody>
|
||||
<ModalFooter p="4">
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function UnmuteModal({}) {
|
||||
const toast = useToast();
|
||||
const account = useCurrentAccount()!;
|
||||
const { requestSignature } = useSigningContext();
|
||||
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
|
||||
|
||||
const modal = useDisclosure();
|
||||
const removeExpiredMutes = async () => {
|
||||
if (!muteList) return;
|
||||
try {
|
||||
// unmute users
|
||||
let draft: DraftNostrEvent = cloneList(muteList);
|
||||
draft = pruneExpiredPubkeys(muteList);
|
||||
|
||||
const signed = await requestSignature(draft);
|
||||
new NostrPublishAction("Unmute Users", clientRelaysService.getWriteUrls(), signed);
|
||||
replaceableEventLoaderService.handleEvent(signed);
|
||||
modal.onClose();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const getExpiredPubkeys = () => {
|
||||
if (!muteList) return [];
|
||||
const now = dayjs().unix();
|
||||
const expirations = getPubkeysExpiration(muteList);
|
||||
return Object.entries(expirations)
|
||||
.filter(([pubkey, ex]) => ex < now)
|
||||
.map(([pubkey]) => pubkey);
|
||||
};
|
||||
useInterval(() => {
|
||||
if (!muteList) return;
|
||||
if (!modal.isOpen && getExpiredPubkeys().length > 0) {
|
||||
modal.onOpen();
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
return (
|
||||
<Modal onClose={modal.onClose} size="lg" isOpen={modal.isOpen}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">Unmute temporary muted users</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody display="flex" flexWrap="wrap" gap="2" px="4" py="0">
|
||||
{getExpiredPubkeys().map((pubkey) => (
|
||||
<Flex gap="2" key={pubkey} alignItems="center">
|
||||
<UserAvatar pubkey={pubkey} size="sm" />
|
||||
<UserLink pubkey={pubkey} fontWeight="bold" />
|
||||
</Flex>
|
||||
))}
|
||||
</ModalBody>
|
||||
<ModalFooter p="4">
|
||||
<Button onClick={modal.onClose} mr="3">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme="brand" onClick={removeExpiredMutes}>
|
||||
Unmute all
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MuteModalProvider({ children }: PropsWithChildren) {
|
||||
const [muteUser, setMuteUser] = useState("");
|
||||
|
||||
const openModal = useCallback(
|
||||
(pubkey: string) => {
|
||||
setMuteUser(pubkey);
|
||||
},
|
||||
[setMuteUser],
|
||||
);
|
||||
|
||||
const context = useMemo(() => ({ openModal }), [openModal]);
|
||||
|
||||
return (
|
||||
<MuteModalContext.Provider value={context}>
|
||||
{children}
|
||||
<UnmuteModal />
|
||||
{muteUser && <MuteModal isOpen onClose={() => setMuteUser("")} pubkey={muteUser} />}
|
||||
</MuteModalContext.Provider>
|
||||
);
|
||||
}
|
@ -16,6 +16,7 @@ class AccountService {
|
||||
loading = new PersistentSubject(true);
|
||||
accounts = new PersistentSubject<Account[]>([]);
|
||||
current = new PersistentSubject<Account | null>(null);
|
||||
isGhost = new PersistentSubject(false);
|
||||
|
||||
constructor() {
|
||||
db.getAll("accounts").then((accounts) => {
|
||||
@ -30,8 +31,26 @@ class AccountService {
|
||||
});
|
||||
}
|
||||
|
||||
startGhost(pubkey: string) {
|
||||
const ghostAccount: Account = {
|
||||
pubkey,
|
||||
readonly: true,
|
||||
};
|
||||
|
||||
const lastPubkey = this.current.value?.pubkey;
|
||||
if (lastPubkey && this.hasAccount(lastPubkey)) localStorage.setItem("lastAccount", lastPubkey);
|
||||
this.current.next(ghostAccount);
|
||||
this.isGhost.next(true);
|
||||
}
|
||||
stopGhost() {
|
||||
const lastAccount = localStorage.getItem("lastAccount");
|
||||
if (lastAccount && this.hasAccount(lastAccount)) {
|
||||
this.switchAccount(lastAccount);
|
||||
} else this.logout();
|
||||
}
|
||||
|
||||
hasAccount(pubkey: string) {
|
||||
return this.accounts.value.some((acc) => acc.pubkey === pubkey);
|
||||
return this.accounts.value.some((account) => account.pubkey === pubkey);
|
||||
}
|
||||
addAccount(account: Account) {
|
||||
if (this.hasAccount(account.pubkey)) {
|
||||
@ -41,6 +60,7 @@ class AccountService {
|
||||
// if this is the current account. update it
|
||||
if (this.current.value?.pubkey === account.pubkey) {
|
||||
this.current.next(account);
|
||||
this.isGhost.next(false);
|
||||
}
|
||||
} else {
|
||||
// add account
|
||||
@ -69,15 +89,14 @@ class AccountService {
|
||||
const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
|
||||
if (account) {
|
||||
this.current.next(account);
|
||||
this.isGhost.next(false);
|
||||
localStorage.setItem("lastAccount", pubkey);
|
||||
}
|
||||
}
|
||||
switchToTemporary(account: Account) {
|
||||
this.current.next(account);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.current.next(null);
|
||||
this.isGhost.next(false);
|
||||
localStorage.removeItem("lastAccount");
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { fetchWithCorsFallback } from "../helpers/cors";
|
||||
import { getLudEndpoint } from "../helpers/lnurl";
|
||||
|
||||
type LNURLPMetadata = {
|
||||
@ -23,7 +24,9 @@ class LNURLMetadataService {
|
||||
const url = getLudEndpoint(addressOrLNURL);
|
||||
if (!url) return;
|
||||
try {
|
||||
const metadata = await fetch(url).then((res) => res.json() as Promise<LNURLError | LNURLPMetadata>);
|
||||
const metadata = await fetchWithCorsFallback(url).then(
|
||||
(res) => res.json() as Promise<LNURLError | LNURLPMetadata>,
|
||||
);
|
||||
if ((metadata as LNURLPMetadata).tag === "payRequest") {
|
||||
return metadata as LNURLPMetadata;
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
|
||||
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
|
||||
@ -22,7 +22,7 @@ export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & O
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<CustomMenuIconButton {...props}>
|
||||
{naddr && (
|
||||
<>
|
||||
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
|
||||
@ -41,7 +41,7 @@ export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & O
|
||||
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
</MenuIconButton>
|
||||
</CustomMenuIconButton>
|
||||
|
||||
{infoModal.isOpen && (
|
||||
<NoteDebugModal event={badge} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
|
||||
|
@ -17,6 +17,7 @@ function CommunityCard({ community, ...props }: Omit<CardProps, "children"> & {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(community));
|
||||
|
||||
const name = getCommunityName(community);
|
||||
const image = getCommunityImage(community);
|
||||
|
||||
return (
|
||||
@ -31,17 +32,14 @@ function CommunityCard({ community, ...props }: Omit<CardProps, "children"> & {
|
||||
/>
|
||||
) : (
|
||||
<Center aspectRatio={3 / 1} fontWeight="bold" fontSize="2xl">
|
||||
{getCommunityName(community)}
|
||||
{name}
|
||||
</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 as={RouterLink} to={`/c/${encodeURIComponent(name)}/${nip19.npubEncode(community.pubkey)}`}>
|
||||
{name}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text>Created by:</Text>
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { Box, BoxProps, Button } from "@chakra-ui/react";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { getCommunityDescription } from "../../../helpers/nostr/communities";
|
||||
import { EmbedableContent, embedUrls, truncateEmbedableContent } from "../../../helpers/embeds";
|
||||
@ -7,19 +9,28 @@ import { renderGenericUrl } from "../../../components/embed-types";
|
||||
export default function CommunityDescription({
|
||||
community,
|
||||
maxLength,
|
||||
showExpand,
|
||||
...props
|
||||
}: Omit<BoxProps, "children"> & { community: NostrEvent; maxLength?: number }) {
|
||||
}: Omit<BoxProps, "children"> & { community: NostrEvent; maxLength?: number; showExpand?: boolean }) {
|
||||
const description = getCommunityDescription(community);
|
||||
let content: EmbedableContent = description ? [description] : [];
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
content = embedUrls(content, [renderGenericUrl]);
|
||||
if (maxLength !== undefined) {
|
||||
if (maxLength !== undefined && !showAll) {
|
||||
content = truncateEmbedableContent(content, maxLength);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{content}
|
||||
</Box>
|
||||
<>
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
{content}
|
||||
</Box>
|
||||
{maxLength !== undefined && showExpand && !showAll && (description?.length ?? 0) > maxLength && (
|
||||
<Button variant="link" onClick={() => setShowAll(true)}>
|
||||
Show More
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,13 +1,16 @@
|
||||
import { Avatar, Box, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
import { useRef } from "react";
|
||||
import { Box, Button, Card, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
import {
|
||||
COMMUNITY_APPROVAL_KIND,
|
||||
getCOmmunityRelays,
|
||||
getApprovedEmbeddedNote,
|
||||
getCOmmunityRelays as getCommunityRelays,
|
||||
getCommunityImage,
|
||||
getCommunityMods,
|
||||
getCommunityName,
|
||||
getCommunityDescription,
|
||||
} from "../../helpers/nostr/communities";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { NostrEvent, isETag } from "../../types/nostr-event";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||
import { UserLink } from "../../components/user-link";
|
||||
@ -18,16 +21,79 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { unique } from "../../helpers/array";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||
import Note from "../../components/note";
|
||||
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
|
||||
import CommunityJoinButton from "../communities/components/community-subscribe-button";
|
||||
import useSingleEvent from "../../hooks/use-single-event";
|
||||
import { EmbedEvent } from "../../components/embed-event";
|
||||
import { AdditionalRelayProvider, useAdditionalRelayContext } from "../../providers/additional-relay-context";
|
||||
import { RelayIconStack } from "../../components/relay-icon-stack";
|
||||
|
||||
function ApprovedEvent({ approval }: { approval: NostrEvent }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(approval));
|
||||
|
||||
const additionalRelays = useAdditionalRelayContext();
|
||||
const embeddedEvent = getApprovedEmbeddedNote(approval);
|
||||
const eventTag = approval.tags.find(isETag);
|
||||
|
||||
const loadEvent = useSingleEvent(
|
||||
eventTag?.[1],
|
||||
eventTag?.[2] ? [eventTag[2], ...additionalRelays] : additionalRelays,
|
||||
);
|
||||
const event = loadEvent || embeddedEvent;
|
||||
if (!event) return;
|
||||
return (
|
||||
<Box ref={ref}>
|
||||
<EmbedEvent event={event} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityDetails({ community }: { community: NostrEvent }) {
|
||||
const communityRelays = getCommunityRelays(community);
|
||||
const mods = getCommunityMods(community);
|
||||
const description = getCommunityDescription(community);
|
||||
|
||||
return (
|
||||
<Card p="2" w="xs" flexShrink={0}>
|
||||
{description && (
|
||||
<>
|
||||
<Heading size="sm">Description:</Heading>
|
||||
<CommunityDescription community={community} maxLength={256} showExpand />
|
||||
</>
|
||||
)}
|
||||
<Heading size="sm" mt="2">
|
||||
Moderators:
|
||||
</Heading>
|
||||
<Flex direction="column" gap="2">
|
||||
{mods.map((pubkey) => (
|
||||
<Flex gap="2">
|
||||
<UserAvatarLink pubkey={pubkey} size="xs" />
|
||||
<UserLink pubkey={pubkey} />
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
{communityRelays.length > 0 && (
|
||||
<>
|
||||
<Heading size="sm" mt="2">
|
||||
Relays:
|
||||
</Heading>
|
||||
<Flex direction="column" gap="2">
|
||||
<RelayIconStack relays={communityRelays} />
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommunityHomePage({ community }: { community: NostrEvent }) {
|
||||
const mods = getCommunityMods(community);
|
||||
const image = getCommunityImage(community);
|
||||
|
||||
const readRelays = useReadRelayUrls(getCOmmunityRelays(community));
|
||||
const timeline = useTimelineLoader(`${getEventUID(community)}-appoved-posts`, readRelays, {
|
||||
const communityRelays = getCommunityRelays(community);
|
||||
const readRelays = useReadRelayUrls(communityRelays);
|
||||
const timeline = useTimelineLoader(`${getEventUID(community)}-approved-posts`, readRelays, {
|
||||
authors: unique([community.pubkey, ...mods]),
|
||||
kinds: [COMMUNITY_APPROVAL_KIND],
|
||||
"#a": [getEventCoordinate(community)],
|
||||
@ -37,40 +103,39 @@ export default function CommunityHomePage({ community }: { community: NostrEvent
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
{image && (
|
||||
<Box
|
||||
backgroundImage={getCommunityImage(community)}
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
aspectRatio={4 / 1}
|
||||
/>
|
||||
)}
|
||||
<Flex wrap="wrap" gap="2" alignItems="center">
|
||||
<Heading size="lg">{getCommunityName(community)}</Heading>
|
||||
<Text>Created by:</Text>
|
||||
<Flex gap="2">
|
||||
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
|
||||
</Flex>
|
||||
<CommunityJoinButton community={community} ml="auto" />
|
||||
</Flex>
|
||||
<CommunityDescription community={community} />
|
||||
<Flex wrap="wrap" gap="2">
|
||||
<Text>Moderators:</Text>
|
||||
{mods.map((pubkey) => (
|
||||
<AdditionalRelayProvider relays={communityRelays}>
|
||||
<VerticalPageLayout pt={image && "0"}>
|
||||
{image && (
|
||||
<Box
|
||||
backgroundImage={getCommunityImage(community)}
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
aspectRatio={3 / 1}
|
||||
backgroundColor="rgba(0,0,0,0.2)"
|
||||
/>
|
||||
)}
|
||||
<Flex wrap="wrap" gap="2" alignItems="center">
|
||||
<Heading size="lg">{getCommunityName(community)}</Heading>
|
||||
<Text>Created by:</Text>
|
||||
<Flex gap="2">
|
||||
<UserAvatarLink pubkey={pubkey} size="xs" />
|
||||
<UserLink pubkey={pubkey} />
|
||||
<UserAvatarLink pubkey={community.pubkey} size="xs" /> <UserLink pubkey={community.pubkey} />
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
<CommunityJoinButton community={community} ml="auto" />
|
||||
</Flex>
|
||||
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
{approvals.map((approval) => (
|
||||
<Note key={getEventUID(approval)} event={approval} />
|
||||
))}
|
||||
</IntersectionObserverProvider>
|
||||
</VerticalPageLayout>
|
||||
<Flex gap="2" alignItems="flex-start">
|
||||
<Flex direction="column" gap="2" flex={1}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
{approvals.map((approval) => (
|
||||
<ApprovedEvent key={getEventUID(approval)} approval={approval} />
|
||||
))}
|
||||
</IntersectionObserverProvider>
|
||||
</Flex>
|
||||
|
||||
<CommunityDetails community={community} />
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
</AdditionalRelayProvider>
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
|
||||
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
|
||||
@ -25,7 +25,7 @@ export default function EmojiPackMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<CustomMenuIconButton {...props}>
|
||||
{naddr && (
|
||||
<>
|
||||
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
|
||||
@ -44,7 +44,7 @@ export default function EmojiPackMenu({
|
||||
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
</MenuIconButton>
|
||||
</CustomMenuIconButton>
|
||||
|
||||
{infoModal.isOpen && (
|
||||
<NoteDebugModal event={pack} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
|
||||
|
@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
|
||||
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
|
||||
@ -21,7 +21,7 @@ export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<CustomMenuIconButton {...props}>
|
||||
{nevent && (
|
||||
<>
|
||||
<MenuItem onClick={() => window.open(buildAppSelectUrl(nevent), "_blank")} icon={<ExternalLinkIcon />}>
|
||||
@ -40,7 +40,7 @@ export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit
|
||||
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
</MenuIconButton>
|
||||
</CustomMenuIconButton>
|
||||
|
||||
{infoModal.isOpen && (
|
||||
<NoteDebugModal event={goal} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
|
||||
|
@ -30,7 +30,7 @@ function HomePage() {
|
||||
const { relays } = useRelaySelectionContext();
|
||||
const { listId, filter } = usePeopleListContext();
|
||||
|
||||
const kinds = [Kind.Text, Kind.Repost, Kind.Article, 2];
|
||||
const kinds = [Kind.Text, Kind.Repost, Kind.Article, Kind.RecommendRelay];
|
||||
const query = useMemo<NostrRequestFilter>(() => {
|
||||
if (filter === undefined) return { kinds };
|
||||
return { ...filter, kinds };
|
||||
|
@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
|
||||
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
|
||||
@ -23,7 +23,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<CustomMenuIconButton {...props}>
|
||||
{naddr && (
|
||||
<>
|
||||
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
|
||||
@ -42,7 +42,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
|
||||
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
</MenuIconButton>
|
||||
</CustomMenuIconButton>
|
||||
|
||||
{infoModal.isOpen && (
|
||||
<NoteDebugModal event={list} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
getParsedCordsFromList,
|
||||
getPubkeysFromList,
|
||||
getReferencesFromList,
|
||||
isSpecialListKind,
|
||||
} from "../../helpers/nostr/lists";
|
||||
import useReplaceableEvent from "../../hooks/use-replaceable-event";
|
||||
import UserCard from "./components/user-card";
|
||||
@ -77,7 +78,7 @@ export default function ListDetailsView() {
|
||||
<Spacer />
|
||||
|
||||
<ListFeedButton list={list} />
|
||||
{isAuthor && (
|
||||
{isAuthor && !isSpecialListKind(list.kind) && (
|
||||
<Button colorScheme="red" onClick={() => deleteEvent(list).then(() => navigate("/lists"))}>
|
||||
Delete
|
||||
</Button>
|
||||
|
@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Button, Card, CardBody, Flex, IconButton, Textarea, useToast } from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
import { Link, Navigate, useParams } from "react-router-dom";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ArrowLeftSIcon } from "../../components/icons";
|
||||
import { UserAvatar } from "../../components/user-avatar";
|
||||
@ -21,9 +21,11 @@ import IntersectionObserverProvider from "../../providers/intersection-observer"
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||
import NostrPublishAction from "../../classes/nostr-publish-action";
|
||||
import { LightboxProvider } from "../../components/lightbox-provider";
|
||||
|
||||
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const account = useCurrentAccount()!;
|
||||
const { requestEncrypt, requestSignature } = useSigningContext();
|
||||
const [content, setContent] = useState<string>("");
|
||||
@ -67,36 +69,31 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex height="100%" overflow="hidden" direction="column">
|
||||
<Card size="sm" flexShrink={0}>
|
||||
<CardBody display="flex" gap="2" alignItems="center">
|
||||
<IconButton
|
||||
as={Link}
|
||||
variant="ghost"
|
||||
icon={<ArrowLeftSIcon />}
|
||||
aria-label="Back"
|
||||
to="/dm"
|
||||
size={["sm", "md"]}
|
||||
/>
|
||||
<UserAvatar pubkey={pubkey} size={["sm", "md"]} />
|
||||
<UserLink pubkey={pubkey} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Flex flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="4" py="4">
|
||||
{[...messages].map((event) => (
|
||||
<Message key={event.id} event={event} />
|
||||
))}
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
<LightboxProvider>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex height="100%" overflow="hidden" direction="column">
|
||||
<Card size="sm" flexShrink={0}>
|
||||
<CardBody display="flex" gap="2" alignItems="center">
|
||||
<IconButton variant="ghost" icon={<ArrowLeftSIcon />} aria-label="Back" onClick={() => navigate(-1)} />
|
||||
<UserAvatar pubkey={pubkey} size="sm" />
|
||||
<UserLink pubkey={pubkey} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Flex flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
|
||||
{[...messages].map((event) => (
|
||||
<Message key={event.id} event={event} />
|
||||
))}
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</Flex>
|
||||
<Flex shrink={0}>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
|
||||
<Button isDisabled={!content} onClick={sendMessage}>
|
||||
Send
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex shrink={0}>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
|
||||
<Button isDisabled={!content} onClick={sendMessage}>
|
||||
Send
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</IntersectionObserverProvider>
|
||||
</IntersectionObserverProvider>
|
||||
</LightboxProvider>
|
||||
);
|
||||
}
|
||||
export default function DirectMessageChatView() {
|
||||
|
@ -27,7 +27,7 @@ export default function DecryptPlaceholder({
|
||||
return children(decrypted);
|
||||
}
|
||||
return (
|
||||
<Button variant="text" onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="100%">
|
||||
<Button onClick={decrypt} isLoading={loading} leftIcon={<UnlockIcon />} width="full">
|
||||
Decrypt
|
||||
</Button>
|
||||
);
|
||||
|
@ -30,24 +30,15 @@ export function Message({ event }: { event: NostrEvent } & Omit<CardProps, "chil
|
||||
useRegisterIntersectionEntity(ref, getEventUID(event));
|
||||
|
||||
return (
|
||||
<Flex direction="column" ref={ref}>
|
||||
<Card size="sm">
|
||||
<CardHeader display="flex" gap="2" alignItems="center" pb="0">
|
||||
<UserAvatar pubkey={event.pubkey} size="xs" />
|
||||
<Heading size="md">
|
||||
<UserLink pubkey={event.pubkey} />
|
||||
</Heading>
|
||||
<Timestamp ml="auto" timestamp={event.created_at} />
|
||||
</CardHeader>
|
||||
<CardBody position="relative">
|
||||
<DecryptPlaceholder
|
||||
data={event.content}
|
||||
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
|
||||
>
|
||||
{(text) => <MessageContent event={event} text={text} />}
|
||||
</DecryptPlaceholder>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Flex direction="column" gap="2" ref={ref}>
|
||||
<Flex gap="2" mr="2">
|
||||
<UserAvatar pubkey={event.pubkey} size="xs" />
|
||||
<UserLink pubkey={event.pubkey} fontWeight="bold" />
|
||||
<Timestamp ml="auto" timestamp={event.created_at} />
|
||||
</Flex>
|
||||
<DecryptPlaceholder data={event.content} pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}>
|
||||
{(text) => <MessageContent event={event} text={text} />}
|
||||
</DecryptPlaceholder>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Button, ButtonGroup, Flex, useToast } from "@chakra-ui/react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Box, Button, ButtonGroup, Flex, IconButton, VisuallyHiddenInput, useToast } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Kind } from "nostr-tools";
|
||||
import dayjs from "dayjs";
|
||||
@ -20,10 +20,12 @@ import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import { useWriteRelayUrls } from "../../../hooks/use-client-relays";
|
||||
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||
import { unique } from "../../../helpers/array";
|
||||
import MagicTextArea from "../../../components/magic-textarea";
|
||||
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
|
||||
import { useContextEmojis } from "../../../providers/emoji-provider";
|
||||
import UserDirectoryProvider from "../../../providers/user-directory-provider";
|
||||
import { TrustProvider } from "../../../providers/trust";
|
||||
import { nostrBuildUploadImage } from "../../../helpers/nostr-build";
|
||||
import { ImageIcon } from "../../../components/icons";
|
||||
|
||||
export type ReplyFormProps = {
|
||||
item: ThreadItem;
|
||||
@ -49,6 +51,31 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
||||
|
||||
watch("content");
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const imageUploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const uploadImage = useCallback(
|
||||
async (imageFile: File) => {
|
||||
try {
|
||||
if (!imageFile.type.includes("image")) throw new Error("Only images are supported");
|
||||
|
||||
setUploading(true);
|
||||
const response = await nostrBuildUploadImage(imageFile, requestSignature);
|
||||
const imageUrl = response.url;
|
||||
|
||||
const content = getValues().content;
|
||||
const position = textAreaRef.current?.getCaretPosition();
|
||||
if (position !== undefined) {
|
||||
setValue("content", content.slice(0, position) + imageUrl + content.slice(position));
|
||||
} else setValue("content", content + imageUrl);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
setUploading(false);
|
||||
},
|
||||
[setValue, getValues],
|
||||
);
|
||||
|
||||
const draft = useMemo(() => {
|
||||
let updated = finalizeNote({ kind: Kind.Text, content: getValues().content, created_at: dayjs().unix(), tags: [] });
|
||||
updated = createEmojiTags(updated, emojis);
|
||||
@ -70,16 +97,47 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
||||
|
||||
return (
|
||||
<UserDirectoryProvider getDirectory={() => threadMembers}>
|
||||
<Flex as="form" direction="column" gap="2" onSubmit={submit}>
|
||||
<Flex as="form" direction="column" gap="2" pb="4" onSubmit={submit}>
|
||||
<MagicTextArea
|
||||
placeholder="Reply"
|
||||
autoFocus
|
||||
mb="2"
|
||||
rows={5}
|
||||
rows={4}
|
||||
isRequired
|
||||
value={getValues().content}
|
||||
onChange={(e) => setValue("content", e.target.value)}
|
||||
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||
onPaste={(e) => {
|
||||
const imageFile = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (imageFile) uploadImage(imageFile);
|
||||
}}
|
||||
/>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={imageUploadRef}
|
||||
onChange={(e) => {
|
||||
const img = e.target.files?.[0];
|
||||
if (img) uploadImage(img);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<ImageIcon />}
|
||||
aria-label="Upload Image"
|
||||
title="Upload Image"
|
||||
onClick={() => imageUploadRef.current?.click()}
|
||||
isLoading={uploading}
|
||||
size="sm"
|
||||
/>
|
||||
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" colorScheme="brand" size="sm">
|
||||
Submit
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
{getValues().content.length > 0 && (
|
||||
<Box p="2" borderWidth={1} borderRadius="md" mb="2">
|
||||
<TrustProvider trust>
|
||||
@ -87,15 +145,6 @@ export default function ReplyForm({ item, onCancel, onSubmitted }: ReplyFormProp
|
||||
</TrustProvider>
|
||||
</Box>
|
||||
)}
|
||||
<Flex gap="2" alignItems="center">
|
||||
<ButtonGroup size="sm">
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ButtonGroup>
|
||||
<UserAvatarStack label="Notify" pubkeys={notifyPubkeys} />
|
||||
<Button type="submit" colorScheme="brand" size="sm" ml="auto">
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</UserDirectoryProvider>
|
||||
);
|
||||
|
@ -20,23 +20,28 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
|
||||
const showReplyForm = useDisclosure();
|
||||
|
||||
const muteFilter = useClientSideMuteFilter();
|
||||
const [alwaysShow, setAlwaysShow] = useState(false);
|
||||
|
||||
const numberOfReplies = countReplies(post);
|
||||
const replies = post.replies.filter((r) => !muteFilter(r.event));
|
||||
const numberOfReplies = countReplies(replies);
|
||||
const isMuted = muteFilter(post.event);
|
||||
|
||||
if (isMuted && numberOfReplies === 0) return null;
|
||||
const [alwaysShow, setAlwaysShow] = useState(false);
|
||||
const muteAlert = (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Muted user or note
|
||||
<Button size="xs" ml="auto" onClick={() => setAlwaysShow(true)}>
|
||||
Show anyway
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
if (isMuted && replies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2">
|
||||
{isMuted && !alwaysShow ? (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Muted user or note
|
||||
<Button size="xs" ml="auto" onClick={() => setAlwaysShow(true)}>
|
||||
Show anyway
|
||||
</Button>
|
||||
</Alert>
|
||||
muteAlert
|
||||
) : (
|
||||
<TrustProvider trust={focusId === post.event.id ? true : undefined}>
|
||||
<Note event={post.event} borderColor={focusId === post.event.id ? "blue.500" : undefined} hideDrawerButton />
|
||||
@ -52,7 +57,7 @@ export const ThreadPost = ({ post, initShowReplies, focusId }: ThreadItemProps)
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{post.replies.length > 0 && (
|
||||
{replies.length > 0 && (
|
||||
<Button onClick={toggle}>
|
||||
{numberOfReplies} {numberOfReplies > 1 ? "Replies" : "Reply"}
|
||||
{showReplies ? <ArrowDownSIcon /> : <ArrowUpSIcon />}
|
||||
|
@ -15,6 +15,7 @@ import Timestamp from "../../components/timestamp";
|
||||
import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event";
|
||||
import EmbeddedUnknown from "../../components/embed-event/event-types/embedded-unknown";
|
||||
import { NoteContents } from "../../components/note/note-contents";
|
||||
import { ErrorBoundary } from "../../components/error-boundary";
|
||||
|
||||
const Kind1Notification = forwardRef<HTMLDivElement, { event: NostrEvent }>(({ event }, ref) => {
|
||||
const refs = getReferences(event);
|
||||
@ -133,18 +134,25 @@ const NotificationItem = ({ event }: { event: NostrEvent }) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, getEventUID(event));
|
||||
|
||||
let content: ReactNode | null = null;
|
||||
switch (event.kind) {
|
||||
case Kind.Text:
|
||||
return <Kind1Notification event={event} ref={ref} />;
|
||||
content = <Kind1Notification event={event} ref={ref} />;
|
||||
break;
|
||||
case Kind.Reaction:
|
||||
return <ReactionNotification event={event} ref={ref} />;
|
||||
content = <ReactionNotification event={event} ref={ref} />;
|
||||
break;
|
||||
case Kind.Repost:
|
||||
return <ShareNotification event={event} ref={ref} />;
|
||||
content = <ShareNotification event={event} ref={ref} />;
|
||||
break;
|
||||
case Kind.Zap:
|
||||
return <ZapNotification event={event} ref={ref} />;
|
||||
content = <ZapNotification event={event} ref={ref} />;
|
||||
break;
|
||||
default:
|
||||
return <EmbeddedUnknown event={event} />;
|
||||
content = <EmbeddedUnknown event={event} />;
|
||||
break;
|
||||
}
|
||||
return content && <ErrorBoundary>{content}</ErrorBoundary>;
|
||||
};
|
||||
|
||||
export default memo(NotificationItem);
|
||||
|
@ -28,7 +28,7 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { useRelayInfo } from "../../../hooks/use-relay-info";
|
||||
import { RelayFavicon } from "../../../components/relay-favicon";
|
||||
import { CodeIcon, RepostIcon } from "../../../components/icons";
|
||||
import { CodeIcon } from "../../../components/icons";
|
||||
import { UserLink } from "../../../components/user-link";
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { useClientRelays } from "../../../hooks/use-client-relays";
|
||||
@ -160,7 +160,7 @@ export default function RelayCard({ url, ...props }: { url: string } & Omit<Card
|
||||
<>
|
||||
<Card variant="outline" {...props}>
|
||||
<CardHeader display="flex" gap="2" alignItems="center" p="2">
|
||||
<RelayFavicon relay={url} size="xs" />
|
||||
<RelayFavicon relay={url} size="sm" />
|
||||
<Heading size="md" isTruncated>
|
||||
<RouterLink to={`/r/${encodeURIComponent(url)}`}>{url}</RouterLink>
|
||||
<RelayPaidTag url={url} />
|
||||
|
@ -25,6 +25,7 @@ export default function RelaysView() {
|
||||
.filter((r) => !clientRelays.includes(r.url))
|
||||
.map((r) => r.url)
|
||||
.filter(safeRelayUrl);
|
||||
|
||||
const { value: onlineRelays = [] } = useAsync(async () =>
|
||||
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>),
|
||||
);
|
||||
@ -42,6 +43,9 @@ export default function RelaysView() {
|
||||
<Flex alignItems="center" gap="2" wrap="wrap">
|
||||
<Input type="search" placeholder="search" value={search} onChange={(e) => setSearch(e.target.value)} w="auto" />
|
||||
<Spacer />
|
||||
<Button as={RouterLink} to="/relays/popular">
|
||||
Popular Relays
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/relays/reviews">
|
||||
Browse Reviews
|
||||
</Button>
|
||||
@ -57,7 +61,7 @@ export default function RelaysView() {
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{discoveredRelays && !isSearching && (
|
||||
{discoveredRelays.length > 0 && !isSearching && (
|
||||
<>
|
||||
<Divider />
|
||||
<Heading size="lg">Discovered Relays</Heading>
|
||||
|
107
src/views/relays/popular.tsx
Normal file
107
src/views/relays/popular.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import {
|
||||
AvatarGroup,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Flex,
|
||||
Heading,
|
||||
LinkBox,
|
||||
LinkOverlay,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { memo } from "react";
|
||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { getPubkeysFromList } from "../../helpers/nostr/lists";
|
||||
import { useClientRelays, useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import useSubjects from "../../hooks/use-subjects";
|
||||
import useUserContactList from "../../hooks/use-user-contact-list";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import userRelaysService from "../../services/user-relays";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { RelayFavicon } from "../../components/relay-favicon";
|
||||
import { ArrowLeftSIcon } from "../../components/icons";
|
||||
import { UserAvatar } from "../../components/user-avatar";
|
||||
import { RelayMetadata, RelayPaidTag } from "./components/relay-card";
|
||||
|
||||
function usePopularContactsRelays(list?: NostrEvent) {
|
||||
const readRelays = useReadRelayUrls();
|
||||
const subs = list ? getPubkeysFromList(list).map((p) => userRelaysService.requestRelays(p.pubkey, readRelays)) : [];
|
||||
const contactsRelays = useSubjects(subs);
|
||||
|
||||
const relayScore: Record<string, string[]> = {};
|
||||
for (const { relays, pubkey } of contactsRelays) {
|
||||
for (const { url } of relays) {
|
||||
relayScore[url] = relayScore[url] || [];
|
||||
relayScore[url].push(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
const relayUrls = Array.from(Object.entries(relayScore)).map(([url, pubkeys]) => ({ url, pubkeys }));
|
||||
|
||||
return relayUrls.sort((a, b) => b.pubkeys.length - a.pubkeys.length);
|
||||
}
|
||||
|
||||
const RelayCard = memo(({ url, pubkeys }: { url: string; pubkeys: string[] }) => {
|
||||
return (
|
||||
<Card variant="outline" as={LinkBox}>
|
||||
<CardHeader px="2" pt="2" pb="0" display="flex" gap="2" alignItems="center">
|
||||
<RelayFavicon relay={url} size="sm" />
|
||||
<Heading size="md" isTruncated>
|
||||
<LinkOverlay as={RouterLink} to={`/r/${encodeURIComponent(url)}`}>
|
||||
{url}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<RelayPaidTag url={url} />
|
||||
</CardHeader>
|
||||
<CardBody p="2">
|
||||
<RelayMetadata url={url} />
|
||||
<Text>Used by {pubkeys.length} contacts:</Text>
|
||||
<AvatarGroup size="sm" max={10}>
|
||||
{pubkeys.map((pubkey) => (
|
||||
<UserAvatar key={pubkey} pubkey={pubkey} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
function PopularRelaysPage() {
|
||||
const navigate = useNavigate();
|
||||
const account = useCurrentAccount();
|
||||
const contacts = useUserContactList(account?.pubkey);
|
||||
|
||||
const clientRelays = useClientRelays().map((r) => r.url);
|
||||
const popularRelays = usePopularContactsRelays(contacts).filter(
|
||||
(r) => !clientRelays.includes(r.url) && r.pubkeys.length > 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
|
||||
Back
|
||||
</Button>
|
||||
<Heading size="md">Popular Relays</Heading>
|
||||
</Flex>
|
||||
<SimpleGrid columns={[1, 1, 1, 2, 3]} spacing="2">
|
||||
{popularRelays.map(({ url, pubkeys }) => (
|
||||
<RelayCard url={url} pubkeys={pubkeys} key={url} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PopularRelaysView() {
|
||||
return (
|
||||
<RequireCurrentAccount>
|
||||
<PopularRelaysPage />
|
||||
</RequireCurrentAccount>
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { Button, Flex } from "@chakra-ui/react";
|
||||
import { Button, Flex, Heading } from "@chakra-ui/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
@ -33,13 +33,14 @@ function RelayReviewsPage() {
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider<string> callback={callback}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2">
|
||||
<Flex gap="2" alignItems="center">
|
||||
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
|
||||
Back
|
||||
</Button>
|
||||
<PeopleListSelection />
|
||||
<Heading size="md">Relay Reviews</Heading>
|
||||
</Flex>
|
||||
{reviews.map((event) => (
|
||||
<RelayReviewNote key={event.id} event={event} />
|
||||
|
@ -32,6 +32,9 @@ export default function StreamZapButton({
|
||||
isDisabled: !zapMetadata.metadata?.allowsNostr,
|
||||
};
|
||||
|
||||
// const zapEvent = goal || stream.event
|
||||
const zapEvent = stream.event;
|
||||
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
@ -45,7 +48,7 @@ export default function StreamZapButton({
|
||||
{zapModal.isOpen && (
|
||||
<ZapModal
|
||||
isOpen
|
||||
event={goal || stream.event}
|
||||
event={zapEvent}
|
||||
pubkey={stream.host}
|
||||
onInvoice={async (invoice) => {
|
||||
if (onZap) onZap();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Box, Button, Flex, useToast } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@ -11,8 +11,9 @@ import { useSigningContext } from "../../../../providers/signing-provider";
|
||||
import NostrPublishAction from "../../../../classes/nostr-publish-action";
|
||||
import { createEmojiTags, ensureNotifyContentMentions } from "../../../../helpers/nostr/post";
|
||||
import { useContextEmojis } from "../../../../providers/emoji-provider";
|
||||
import { MagicInput } from "../../../../components/magic-textarea";
|
||||
import { MagicInput, RefType } from "../../../../components/magic-textarea";
|
||||
import StreamZapButton from "../../components/stream-zap-button";
|
||||
import { nostrBuildUploadImage } from "../../../../helpers/nostr-build";
|
||||
|
||||
export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
|
||||
const toast = useToast();
|
||||
@ -41,6 +42,27 @@ export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
|
||||
}
|
||||
});
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const uploadImage = useCallback(
|
||||
async (imageFile: File) => {
|
||||
try {
|
||||
if (!imageFile.type.includes("image")) throw new Error("Only images are supported");
|
||||
|
||||
const response = await nostrBuildUploadImage(imageFile, requestSignature);
|
||||
const imageUrl = response.url;
|
||||
|
||||
const content = getValues().content;
|
||||
const position = textAreaRef.current?.getCaretPosition();
|
||||
if (position !== undefined) {
|
||||
setValue("content", content.slice(0, position) + imageUrl + content.slice(position));
|
||||
} else setValue("content", content + imageUrl);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
},
|
||||
[setValue, getValues],
|
||||
);
|
||||
|
||||
watch("content");
|
||||
|
||||
return (
|
||||
@ -48,11 +70,16 @@ export default function ChatMessageForm({ stream }: { stream: ParsedStream }) {
|
||||
<Box borderRadius="md" flexShrink={0} display="flex" gap="2" px="2" pb="2">
|
||||
<Flex as="form" onSubmit={sendMessage} gap="2" flex={1}>
|
||||
<MagicInput
|
||||
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||
placeholder="Message"
|
||||
autoComplete="off"
|
||||
isRequired
|
||||
value={getValues().content}
|
||||
onChange={(e) => setValue("content", e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const file = Array.from(e.clipboardData.files).find((f) => f.type.includes("image"));
|
||||
if (file) uploadImage(file);
|
||||
}}
|
||||
/>
|
||||
<Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}>
|
||||
Send
|
||||
|
@ -14,7 +14,7 @@ import useClientSideMuteFilter from "../../../../hooks/use-client-side-mute-filt
|
||||
export default function useStreamChatTimeline(stream: ParsedStream) {
|
||||
const streamRelays = useRelaySelectionRelays();
|
||||
|
||||
const hostMuteFilter = useUserMuteFilter(stream.host);
|
||||
const hostMuteFilter = useUserMuteFilter(stream.host, [], { alwaysRequest: true });
|
||||
const muteFilter = useClientSideMuteFilter();
|
||||
|
||||
const eventFilter = useCallback(
|
||||
@ -23,7 +23,7 @@ export default function useStreamChatTimeline(stream: ParsedStream) {
|
||||
if (stream.ends && event.created_at > stream.ends) return false;
|
||||
return !(hostMuteFilter(event) || muteFilter(event));
|
||||
},
|
||||
[hostMuteFilter, muteFilter],
|
||||
[stream, hostMuteFilter, muteFilter],
|
||||
);
|
||||
|
||||
const goal = useStreamGoal(stream);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Button, Flex, Heading, Image, Link } from "@chakra-ui/react";
|
||||
import { Button, Divider, Flex, Heading, Image, Link } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { ExternalLinkIcon, MapIcon, ToolsIcon } from "../../components/icons";
|
||||
import { ExternalLinkIcon, LiveStreamIcon, MapIcon, ToolsIcon } from "../../components/icons";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import { ConnectedRelays } from "../../components/connected-relays";
|
||||
|
||||
@ -10,14 +10,28 @@ export default function ToolsHomeView() {
|
||||
<Heading>
|
||||
<ToolsIcon /> Tools
|
||||
</Heading>
|
||||
<Divider />
|
||||
<Flex wrap="wrap" gap="4">
|
||||
<Button as={RouterLink} to="/tools/network">
|
||||
Contact network
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/tools/network-graph">
|
||||
Contacts Mute Graph
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/map" leftIcon={<MapIcon />}>
|
||||
Map
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/tools/stream-moderation" leftIcon={<LiveStreamIcon />}>
|
||||
Stream Moderation
|
||||
</Button>
|
||||
<ConnectedRelays />
|
||||
</Flex>
|
||||
|
||||
<Heading size="lg" mt="4">
|
||||
Third party tools
|
||||
</Heading>
|
||||
<Divider />
|
||||
<Flex wrap="wrap" gap="4">
|
||||
<Button
|
||||
as={Link}
|
||||
href="https://w3.do/"
|
||||
|
132
src/views/tools/network-mute-graph.tsx
Normal file
132
src/views/tools/network-mute-graph.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import ForceGraph, { LinkObject, NodeObject } from "react-force-graph-3d";
|
||||
import {
|
||||
Group,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
SRGBColorSpace,
|
||||
SphereGeometry,
|
||||
Sprite,
|
||||
SpriteMaterial,
|
||||
TextureLoader,
|
||||
} from "three";
|
||||
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import { useUsersMetadata } from "../../hooks/use-user-network";
|
||||
import { MUTE_LIST_KIND, getPubkeysFromList, isPubkeyInList } from "../../helpers/nostr/lists";
|
||||
import useUserContactList from "../../hooks/use-user-contact-list";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import replaceableEventLoaderService from "../../services/replaceable-event-requester";
|
||||
import useSubjects from "../../hooks/use-subjects";
|
||||
import { useUserMetadata } from "../../hooks/use-user-metadata";
|
||||
|
||||
export function useUsersMuteLists(pubkeys: string[], additionalRelays: string[] = []) {
|
||||
const readRelays = useReadRelayUrls(additionalRelays);
|
||||
const muteListSubjects = useMemo(() => {
|
||||
return pubkeys.map((pubkey) => replaceableEventLoaderService.requestEvent(readRelays, MUTE_LIST_KIND, pubkey));
|
||||
}, [pubkeys]);
|
||||
return useSubjects(muteListSubjects);
|
||||
}
|
||||
|
||||
type NodeType = { id: string; image?: string; name?: string };
|
||||
|
||||
function NetworkGraphPage() {
|
||||
const account = useCurrentAccount()!;
|
||||
|
||||
const selfMetadata = useUserMetadata(account.pubkey);
|
||||
const contacts = useUserContactList(account.pubkey);
|
||||
const contactsPubkeys = useMemo(
|
||||
() => (contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : []),
|
||||
[contacts],
|
||||
);
|
||||
const usersMetadata = useUsersMetadata(contactsPubkeys);
|
||||
const usersMuteLists = useUsersMuteLists(contactsPubkeys);
|
||||
|
||||
const graphData = useMemo(() => {
|
||||
if (!contacts) return { nodes: [], links: [] };
|
||||
|
||||
const nodes: Record<string, NodeObject<NodeType>> = {};
|
||||
const links: Record<string, LinkObject<NodeType>> = {};
|
||||
|
||||
const getOrCreateNode = (pubkey: string) => {
|
||||
if (!nodes[pubkey]) {
|
||||
const node: NodeType = {
|
||||
id: pubkey,
|
||||
};
|
||||
|
||||
const metadata = usersMetadata[pubkey];
|
||||
if (metadata) {
|
||||
node.image = metadata.picture;
|
||||
node.name = metadata.name;
|
||||
}
|
||||
|
||||
nodes[pubkey] = node;
|
||||
}
|
||||
return nodes[pubkey];
|
||||
};
|
||||
|
||||
for (const muteList of usersMuteLists) {
|
||||
const author = muteList.pubkey;
|
||||
for (const user of getPubkeysFromList(muteList)) {
|
||||
if (isPubkeyInList(contacts, user.pubkey)) {
|
||||
const keyA = [author, user.pubkey].join("|");
|
||||
links[keyA] = { source: getOrCreateNode(author), target: getOrCreateNode(user.pubkey) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: Object.values(nodes), links: Object.values(links) };
|
||||
}, [contacts, usersMuteLists, usersMetadata, selfMetadata]);
|
||||
|
||||
return (
|
||||
<Box overflow="hidden" flex={1}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<ForceGraph<NodeType>
|
||||
graphData={graphData}
|
||||
enableNodeDrag={false}
|
||||
width={width}
|
||||
height={height}
|
||||
linkDirectionalArrowLength={3.5}
|
||||
linkDirectionalArrowRelPos={1}
|
||||
linkCurvature={0.25}
|
||||
nodeThreeObject={(node: NodeType) => {
|
||||
if (!node.image) {
|
||||
return new Mesh(new SphereGeometry(5, 12, 6), new MeshBasicMaterial({ color: 0xaa0f0f }));
|
||||
}
|
||||
|
||||
const group = new Group();
|
||||
|
||||
const imgTexture = new TextureLoader().load(node.image);
|
||||
imgTexture.colorSpace = SRGBColorSpace;
|
||||
const material = new SpriteMaterial({ map: imgTexture });
|
||||
const sprite = new Sprite(material);
|
||||
sprite.scale.set(10, 10, 10);
|
||||
|
||||
group.children.push(sprite);
|
||||
|
||||
// if (node.name) {
|
||||
// const text = new SpriteText(node.name, 8, "ffffff");
|
||||
// text.position.set(0, 0, 16);
|
||||
// group.children.push(text);
|
||||
// }
|
||||
|
||||
return sprite;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkGraphView() {
|
||||
return (
|
||||
<RequireCurrentAccount>
|
||||
<NetworkGraphPage />
|
||||
</RequireCurrentAccount>
|
||||
);
|
||||
}
|
@ -3,7 +3,7 @@ import { memo, useMemo, useState } from "react";
|
||||
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import useUserNetwork from "../../hooks/use-user-network";
|
||||
import { useNetworkConnectionCount } from "../../hooks/use-user-network";
|
||||
import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||
import { UserLink } from "../../components/user-link";
|
||||
import { ArrowLeftSIcon } from "../../components/icons";
|
||||
@ -23,7 +23,7 @@ function NetworkPage() {
|
||||
const account = useCurrentAccount()!;
|
||||
const [range, setRange] = useState("50-100");
|
||||
|
||||
const network = useUserNetwork(account.pubkey);
|
||||
const network = useNetworkConnectionCount(account.pubkey);
|
||||
const filteredPubkeys = useMemo(() => {
|
||||
if (range.endsWith("+")) {
|
||||
const min = parseInt(range.replace("+", ""));
|
||||
|
188
src/views/tools/stream-moderation.tsx
Normal file
188
src/views/tools/stream-moderation.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardProps,
|
||||
Divider,
|
||||
Flex,
|
||||
Heading,
|
||||
Select,
|
||||
} from "@chakra-ui/react";
|
||||
import dayjs from "dayjs";
|
||||
import { Kind } from "nostr-tools";
|
||||
|
||||
import useParsedStreams from "../../hooks/use-parsed-streams";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { ParsedStream, STREAM_KIND, getATag } from "../../helpers/nostr/stream";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { getEventUID } from "../../helpers/nostr/events";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import useStreamChatTimeline from "../streams/stream/stream-chat/use-stream-chat-timeline";
|
||||
import { UserAvatar } from "../../components/user-avatar";
|
||||
import { UserLink } from "../../components/user-link";
|
||||
import StreamChat from "../streams/stream/stream-chat";
|
||||
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
|
||||
import { useMuteModalContext } from "../../providers/mute-modal-provider";
|
||||
import RelaySelectionProvider from "../../providers/relay-selection-provider";
|
||||
import useUserMuteList from "../../hooks/use-user-mute-list";
|
||||
import { isPubkeyInList } from "../../helpers/nostr/lists";
|
||||
import ZapMessageMemo from "../streams/stream/stream-chat/zap-message";
|
||||
|
||||
function UserCard({ pubkey }: { pubkey: string }) {
|
||||
const { isMuted, mute, unmute, expiration } = useUserMuteFunctions(pubkey);
|
||||
const { openModal } = useMuteModalContext();
|
||||
|
||||
let buttons: ReactNode | null = null;
|
||||
if (isMuted) {
|
||||
if (expiration === Infinity) {
|
||||
buttons = <Button onClick={unmute}>Unban</Button>;
|
||||
} else {
|
||||
buttons = <Button onClick={unmute}>Unmute ({dayjs.unix(expiration).fromNow()})</Button>;
|
||||
}
|
||||
} else {
|
||||
buttons = (
|
||||
<>
|
||||
<Button onClick={() => openModal(pubkey)}>Mute</Button>
|
||||
<Button onClick={mute}>Ban</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="row" alignItems="center">
|
||||
{!isMuted && <UserAvatar pubkey={pubkey} noProxy size="sm" />}
|
||||
<UserLink pubkey={pubkey} />
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
{buttons}
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMuteCard({ stream, ...props }: Omit<CardProps, "children"> & { stream: ParsedStream }) {
|
||||
const account = useCurrentAccount()!;
|
||||
const streamChatTimeline = useStreamChatTimeline(stream);
|
||||
|
||||
// refresh when a new event
|
||||
useSubject(streamChatTimeline.events.onEvent);
|
||||
const chatEvents = streamChatTimeline.events.getSortedEvents();
|
||||
|
||||
const muteList = useUserMuteList(account.pubkey);
|
||||
const pubkeysInChat = useMemo(() => {
|
||||
const pubkeys: string[] = [];
|
||||
for (const event of chatEvents) {
|
||||
if (!pubkeys.includes(event.pubkey)) pubkeys.push(event.pubkey);
|
||||
}
|
||||
return pubkeys;
|
||||
}, [chatEvents]);
|
||||
|
||||
const peopleInChat = pubkeysInChat.filter((pubkey) => !isPubkeyInList(muteList, pubkey));
|
||||
const mutedPubkeys = pubkeysInChat.filter((pubkey) => isPubkeyInList(muteList, pubkey));
|
||||
|
||||
return (
|
||||
<Card {...props}>
|
||||
<CardHeader pt="2" px="2" pb="0">
|
||||
<Heading size="md">Users in chat</Heading>
|
||||
</CardHeader>
|
||||
<CardBody p="2" gap="2" display="flex" overflowY="auto" overflowX="hidden" flexDirection="column">
|
||||
{peopleInChat.map((pubkey) => (
|
||||
<UserCard key={pubkey} pubkey={pubkey} />
|
||||
))}
|
||||
{mutedPubkeys.length > 0 && (
|
||||
<>
|
||||
<Heading size="sm">Muted</Heading>
|
||||
<Divider />
|
||||
{mutedPubkeys.map((pubkey) => (
|
||||
<UserCard key={pubkey} pubkey={pubkey} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ZapMessagesCard({ stream, ...props }: Omit<CardProps, "children"> & { stream: ParsedStream }) {
|
||||
const streamChatTimeline = useStreamChatTimeline(stream);
|
||||
|
||||
// refresh when a new event
|
||||
useSubject(streamChatTimeline.events.onEvent);
|
||||
const zapMessages = streamChatTimeline.events.getSortedEvents().filter((event) => event.kind === Kind.Zap);
|
||||
|
||||
return (
|
||||
<Card {...props}>
|
||||
<CardHeader pt="2" px="2" pb="0">
|
||||
<Heading size="md">Zap messages</Heading>
|
||||
</CardHeader>
|
||||
<CardBody p="2" gap="2" display="flex" overflowY="auto" overflowX="hidden" flexDirection="column">
|
||||
{zapMessages.map((event) => (
|
||||
<ZapMessageMemo key={event.id} zap={event} stream={stream} />
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamModerationDashboard({ stream }: { stream: ParsedStream }) {
|
||||
return (
|
||||
<Flex gap="2" overflow="hidden" height="100%">
|
||||
<UserMuteCard stream={stream} flex={1} />
|
||||
<ZapMessagesCard stream={stream} flex={1} />
|
||||
<StreamChat stream={stream} flex={1} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamModerationPage() {
|
||||
const account = useCurrentAccount()!;
|
||||
const readRelays = useReadRelayUrls();
|
||||
|
||||
const timeline = useTimelineLoader(account.pubkey + "-streams", readRelays, [
|
||||
{
|
||||
authors: [account.pubkey],
|
||||
kinds: [STREAM_KIND],
|
||||
},
|
||||
{ "#p": [account.pubkey], kinds: [STREAM_KIND] },
|
||||
]);
|
||||
|
||||
const streamEvents = useSubject(timeline.timeline);
|
||||
const streams = useParsedStreams(streamEvents);
|
||||
|
||||
const [selected, setSelected] = useState<ParsedStream>();
|
||||
|
||||
return (
|
||||
<Flex direction="column" p="2" overflow="hidden" gap="2" h="100vh">
|
||||
<Flex gap="2" flexShrink={0}>
|
||||
<Select
|
||||
placeholder="Select stream"
|
||||
value={selected && getATag(selected)}
|
||||
onChange={(e) => setSelected(streams.find((s) => getATag(s) === e.target.value))}
|
||||
>
|
||||
{streams.map((stream) => (
|
||||
<option key={getEventUID(stream.event)} value={getATag(stream)}>
|
||||
{stream.title} ({stream.status})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Flex>
|
||||
{selected && (
|
||||
<RelaySelectionProvider additionalDefaults={selected.relays ?? []}>
|
||||
<StreamModerationDashboard stream={selected} />
|
||||
</RelaySelectionProvider>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StreamModerationView() {
|
||||
return (
|
||||
<RequireCurrentAccount>
|
||||
<StreamModerationPage />
|
||||
</RequireCurrentAccount>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Flex, Heading, IconButton, Spacer, useBreakpointValue } from "@chakra-ui/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { EditIcon } from "../../../components/icons";
|
||||
import { EditIcon, GhostIcon } from "../../../components/icons";
|
||||
import { UserAvatar } from "../../../components/user-avatar";
|
||||
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
|
||||
import { getUserDisplayName } from "../../../helpers/user-metadata";
|
||||
@ -8,6 +8,7 @@ import { useCurrentAccount } from "../../../hooks/use-current-account";
|
||||
import { useUserMetadata } from "../../../hooks/use-user-metadata";
|
||||
import { UserProfileMenu } from "./user-profile-menu";
|
||||
import { UserFollowButton } from "../../../components/user-follow-button";
|
||||
import accountService from "../../../services/account";
|
||||
|
||||
export default function Header({
|
||||
pubkey,
|
||||
@ -22,7 +23,7 @@ export default function Header({
|
||||
const account = useCurrentAccount();
|
||||
const isSelf = pubkey === account?.pubkey;
|
||||
|
||||
const showFollowButton = useBreakpointValue({ base: false, sm: true });
|
||||
const showExtraButtons = useBreakpointValue({ base: false, sm: true });
|
||||
|
||||
const showFullNip05 = useBreakpointValue({ base: false, md: true });
|
||||
|
||||
@ -35,7 +36,7 @@ export default function Header({
|
||||
</Heading>
|
||||
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon={showFullNip05} />
|
||||
<Spacer />
|
||||
{isSelf && (
|
||||
{isSelf && !account.readonly && (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria-label="Edit profile"
|
||||
@ -45,7 +46,16 @@ export default function Header({
|
||||
onClick={() => navigate("/profile")}
|
||||
/>
|
||||
)}
|
||||
{showFollowButton && !isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
|
||||
{showExtraButtons && !isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
|
||||
{showExtraButtons && !isSelf && (
|
||||
<IconButton
|
||||
icon={<GhostIcon />}
|
||||
size="sm"
|
||||
aria-label="ghost user"
|
||||
title="ghost user"
|
||||
onClick={() => accountService.startGhost(pubkey)}
|
||||
/>
|
||||
)}
|
||||
<UserProfileMenu
|
||||
pubkey={pubkey}
|
||||
aria-label="More Options"
|
||||
|
@ -3,7 +3,7 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
|
||||
import {
|
||||
ChatIcon,
|
||||
ClipboardIcon,
|
||||
@ -54,7 +54,7 @@ export const UserProfileMenu = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
<CustomMenuIconButton {...props}>
|
||||
<MenuItem onClick={() => window.open(buildAppSelectUrl(sharableId), "_blank")} icon={<ExternalLinkIcon />}>
|
||||
View in app...
|
||||
</MenuItem>
|
||||
@ -80,7 +80,7 @@ export const UserProfileMenu = ({
|
||||
Relay selection
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuIconButton>
|
||||
</CustomMenuIconButton>
|
||||
{infoModal.isOpen && (
|
||||
<UserDebugModal pubkey={pubkey} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
|
||||
)}
|
||||
|
@ -76,7 +76,7 @@ const UserRelaysTab = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider<string> callback={callback}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<VStack divider={<StackDivider />} py="2" align="stretch">
|
||||
{userRelays.map((relayConfig) => (
|
||||
<ErrorBoundary>
|
||||
|
@ -30,7 +30,7 @@ export default function UserStreamsTab() {
|
||||
const streams = useParsedStreams(events);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider<string> callback={callback}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<VerticalPageLayout>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="2">
|
||||
{streams.map((stream) => (
|
||||
|
Loading…
x
Reference in New Issue
Block a user