Merge branch 'next'

This commit is contained in:
hzrd149 2023-09-29 11:12:51 -05:00
commit 7d0da2fc6d
85 changed files with 2914 additions and 1125 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add time durations for muting users

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple stream moderation tool

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix issue with freezing when navigating back to main timeline

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add popular relays view

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix broken links in side drawer

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add nostr.build image uploads

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix bug when clicking on shared long form note

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple community views

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add image upload button to reply form

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add ghost mode

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

BIN
screenshots/photography.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
screenshots/profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

BIN
screenshots/streaming.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
screenshots/streams.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -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 /> },
],
},
{

View File

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

View File

@ -35,6 +35,7 @@ export function embedNostrLinks(content: EmbedableContent) {
});
}
/** @deprecated */
export function embedNostrMentions(content: EmbedableContent, event: NostrEvent | DraftNostrEvent) {
return embedJSX(content, {
name: "nostr-mention",

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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%" />}

View File

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

View File

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

View File

@ -52,7 +52,7 @@ export default function TimelinePage({
}
};
return (
<IntersectionObserverProvider<string> callback={callback}>
<IntersectionObserverProvider callback={callback}>
<Flex direction="column" gap="2" {...props}>
{header}
{renderTimeline()}

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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],
};
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

1897
yarn.lock

File diff suppressed because it is too large Load Diff