Merge branch 'next'

This commit is contained in:
hzrd149 2023-07-06 23:47:48 -05:00
commit 655ab6f6f2
56 changed files with 945 additions and 599 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Standardize timeline rendering between views

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Fix preformance bug with large timelines

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add more prominent new post button

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild direct message chat view using timeline loader

View File

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Dont show multiple images on open-graph link card

View File

@ -1,9 +1,8 @@
import React, { Suspense, useEffect } from "react";
import { createHashRouter, Outlet, RouterProvider, ScrollRestoration, useSearchParams } from "react-router-dom";
import { Spinner, useColorMode } from "@chakra-ui/react";
import React, { Suspense } from "react";
import { createHashRouter, Outlet, RouterProvider, ScrollRestoration } from "react-router-dom";
import { Spinner } from "@chakra-ui/react";
import { ErrorBoundary } from "./components/error-boundary";
import { Page } from "./components/page";
import useSubject from "./hooks/use-subject";
import HomeView from "./views/home";
import SettingsView from "./views/settings";
@ -25,19 +24,18 @@ import RelaysView from "./views/relays";
import LoginNip05View from "./views/login/nip05";
import LoginNsecView from "./views/login/nsec";
import UserZapsTab from "./views/user/zaps";
import DirectMessagesView from "./views/dm";
import DirectMessageChatView from "./views/dm/chat";
import DirectMessagesView from "./views/messages";
import DirectMessageChatView from "./views/messages/chat";
import NostrLinkView from "./views/link";
import UserReportsTab from "./views/user/reports";
import appSettings from "./services/app-settings";
import UserMediaTab from "./views/user/media";
import ToolsHomeView from "./views/tools";
import Nip19ToolsView from "./views/tools/nip19";
import UserAboutTab from "./views/user/about";
import UserLikesTab from "./views/user/likes";
import useSetColorMode from "./hooks/use-set-color-mode";
import UserStreamsTab from "./views/user/streams";
const LiveStreamsTab = React.lazy(() => import("./views/streams"));
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
const SearchView = React.lazy(() => import("./views/search"));
@ -77,7 +75,7 @@ const router = createHashRouter([
{ path: "", element: <UserAboutTab /> },
{ path: "about", element: <UserAboutTab /> },
{ path: "notes", element: <UserNotesTab /> },
{ path: "media", element: <UserMediaTab /> },
{ path: "streams", element: <UserStreamsTab /> },
{ path: "zaps", element: <UserZapsTab /> },
{ path: "likes", element: <UserLikesTab /> },
{ path: "followers", element: <UserFollowersTab /> },
@ -106,7 +104,7 @@ const router = createHashRouter([
},
{
path: "streams",
element: <LiveStreamsTab />,
element: <StreamsView />,
},
{ path: "l/:link", element: <NostrLinkView /> },
{ path: "t/:hashtag", element: <HashTagView /> },

View File

@ -1,6 +1,6 @@
import { Subject } from "./subject";
import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage, NostrQuery } from "../types/nostr-query";
import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query";
import { IncomingEvent, Relay } from "./relay";
import relayPoolService from "../services/relay-pool";
@ -13,14 +13,14 @@ export class NostrMultiSubscription {
id: string;
name?: string;
query?: NostrQuery;
query?: NostrRequestFilter;
relayUrls: string[];
relays: Relay[];
state = NostrMultiSubscription.INIT;
onEvent = new Subject<NostrEvent>();
seenEvents = new Set<string>();
constructor(relayUrls: string[], query?: NostrQuery, name?: string) {
constructor(relayUrls: string[], query?: NostrRequestFilter, name?: string) {
this.id = String(name || lastId++);
this.query = query;
this.name = name;
@ -66,16 +66,20 @@ export class NostrMultiSubscription {
if (this.state === NostrMultiSubscription.OPEN) return this;
this.state = NostrMultiSubscription.OPEN;
this.send(["REQ", this.id, this.query]);
if (Array.isArray(this.query)) {
this.send(["REQ", this.id, ...this.query]);
} else this.send(["REQ", this.id, this.query]);
this.subscribeToRelays();
return this;
}
setQuery(query: NostrQuery) {
setQuery(query: NostrRequestFilter) {
this.query = query;
if (this.state === NostrMultiSubscription.OPEN) {
this.send(["REQ", this.id, this.query]);
if (Array.isArray(this.query)) {
this.send(["REQ", this.id, ...this.query]);
} else this.send(["REQ", this.id, this.query]);
}
return this;
}
@ -97,7 +101,9 @@ export class NostrMultiSubscription {
// if the subscription is open and it has a query
if (this.state === NostrMultiSubscription.OPEN && this.query) {
// open a connection to this relay
relay.send(["REQ", this.id, this.query]);
if (Array.isArray(this.query)) {
relay.send(["REQ", this.id, ...this.query]);
} else relay.send(["REQ", this.id, this.query]);
}
}
}

View File

@ -1,5 +1,5 @@
import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { NostrRequestFilter } from "../types/nostr-query";
import relayPoolService from "../services/relay-pool";
import { IncomingEOSE, IncomingEvent, Relay } from "./relay";
import Subject from "./subject";
@ -59,14 +59,16 @@ export class NostrRequest {
}
}
start(query: NostrQuery) {
start(filter: NostrRequestFilter) {
if (this.state !== NostrRequest.IDLE) {
throw new Error("cant restart a nostr request");
}
this.state = NostrRequest.RUNNING;
for (const relay of this.relays) {
relay.send(["REQ", this.id, query]);
if (Array.isArray(filter)) {
relay.send(["REQ", this.id, ...filter]);
} else relay.send(["REQ", this.id, filter]);
}
setTimeout(() => this.complete(), this.timeout);

View File

@ -1,5 +1,5 @@
import { NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage, NostrQuery } from "../types/nostr-query";
import { NostrOutgoingMessage, NostrRequestFilter } from "../types/nostr-query";
import { IncomingEOSE, Relay } from "./relay";
import relayPoolService from "../services/relay-pool";
import { Subject } from "./subject";
@ -13,13 +13,13 @@ export class NostrSubscription {
id: string;
name?: string;
query?: NostrQuery;
query?: NostrRequestFilter;
relay: Relay;
state = NostrSubscription.INIT;
onEvent = new Subject<NostrEvent>();
onEOSE = new Subject<IncomingEOSE>();
constructor(relayUrl: string, query?: NostrQuery, name?: string) {
constructor(relayUrl: string, query?: NostrRequestFilter, name?: string) {
this.id = String(name || lastId++);
this.query = query;
this.name = name;
@ -43,16 +43,20 @@ export class NostrSubscription {
if (this.state === NostrSubscription.OPEN) return this;
this.state = NostrSubscription.OPEN;
this.send(["REQ", this.id, this.query]);
if (Array.isArray(this.query)) {
this.send(["REQ", this.id, ...this.query]);
} else this.send(["REQ", this.id, this.query]);
relayPoolService.addClaim(this.relay.url, this);
return this;
}
setQuery(query: NostrQuery) {
setQuery(query: NostrRequestFilter) {
this.query = query;
if (this.state === NostrSubscription.OPEN) {
this.send(["REQ", this.id, this.query]);
if (Array.isArray(this.query)) {
this.send(["REQ", this.id, ...this.query]);
} else this.send(["REQ", this.id, this.query]);
}
return this;
}

View File

@ -1,18 +1,25 @@
import dayjs from "dayjs";
import { utils } from "nostr-tools";
import { NostrEvent } from "../types/nostr-event";
import { NostrQuery } from "../types/nostr-query";
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
import { NostrRequest } from "./nostr-request";
import { NostrMultiSubscription } from "./nostr-multi-subscription";
import Subject, { PersistentSubject } from "./subject";
function addToQuery(filter: NostrRequestFilter, query: NostrQuery) {
if (Array.isArray(filter)) {
return filter.map((f) => ({ ...f, ...query }));
}
return { ...filter, ...query };
}
const BLOCK_SIZE = 20;
type EventFilter = (event: NostrEvent) => boolean;
class RelayTimelineLoader {
relay: string;
query: NostrQuery;
query: NostrRequestFilter;
blockSize = BLOCK_SIZE;
private name?: string;
private requestId = 0;
@ -25,7 +32,7 @@ class RelayTimelineLoader {
onEvent = new Subject<NostrEvent>();
onBlockFinish = new Subject<void>();
constructor(relay: string, query: NostrQuery, name?: string) {
constructor(relay: string, query: NostrRequestFilter, name?: string) {
this.relay = relay;
this.query = query;
this.name = name;
@ -33,9 +40,9 @@ class RelayTimelineLoader {
loadNextBlock() {
this.loading = true;
const query: NostrQuery = { ...this.query, limit: this.blockSize };
let query: NostrRequestFilter = addToQuery(this.query, { limit: this.blockSize });
if (this.events[this.events.length - 1]) {
query.until = this.events[this.events.length - 1].created_at + 1;
query = addToQuery(query, { until: this.events[this.events.length - 1].created_at + 1 });
}
const request = new NostrRequest([this.relay], undefined, this.name + "-" + this.requestId++);
@ -77,7 +84,7 @@ class RelayTimelineLoader {
export class TimelineLoader {
cursor = dayjs().unix();
query?: NostrQuery;
query?: NostrRequestFilter;
relays: string[] = [];
events = new PersistentSubject<NostrEvent[]>([]);
@ -145,7 +152,7 @@ export class TimelineLoader {
this.subscription.setRelays(relays);
this.updateComplete();
}
setQuery(query: NostrQuery) {
setQuery(query: NostrRequestFilter) {
if (JSON.stringify(this.query) === JSON.stringify(query)) return;
this.removeLoaders();
@ -160,7 +167,7 @@ export class TimelineLoader {
// update the subscription
this.subscription.forgetEvents();
this.subscription.setQuery({ ...query, limit: BLOCK_SIZE / 2 });
this.subscription.setQuery(addToQuery(query, { limit: BLOCK_SIZE / 2 }));
}
setFilter(filter?: (event: NostrEvent) => boolean) {
this.eventFilter = filter;
@ -221,6 +228,12 @@ export class TimelineLoader {
this.subscription.close();
}
reset() {
this.cursor = dayjs().unix();
this.relayTimelineLoaders.clear();
this.forgetEvents();
}
// TODO: this is only needed because the current logic dose not remove events when the relay they where fetched from is removed
/** @deprecated */
forgetEvents() {

View File

@ -1,7 +1,7 @@
import { Box, Code, Flex, Heading } from "@chakra-ui/react";
import { CopyIconButton } from "../copy-icon-button";
export default function RawValue({ value, heading }: { heading: string; value: string }) {
export default function RawValue({ value, heading }: { heading: string; value?: string | null }) {
return (
<Box>
<Heading size="sm" mb="2">
@ -11,7 +11,7 @@ export default function RawValue({ value, heading }: { heading: string; value: s
<Code fontSize="md" wordBreak="break-all">
{value}
</Code>
<CopyIconButton text={value} size="xs" aria-label="copy" />
<CopyIconButton text={String(value)} size="xs" aria-label="copy" />
</Flex>
</Box>
);

View File

@ -1,23 +0,0 @@
import React from "react";
import useSubject from "../hooks/use-subject";
import { TimelineLoader } from "../classes/timeline-loader";
import RepostNote from "./repost-note";
import { Note } from "./note";
const GenericNoteTimeline = React.memo(({ timeline }: { timeline: TimelineLoader }) => {
const notes = useSubject(timeline.timeline);
return (
<>
{notes.map((note) =>
note.kind === 6 ? (
<RepostNote key={note.id} event={note} maxHeight={1200} />
) : (
<Note key={note.id} event={note} maxHeight={1200} />
)
)}
</>
);
});
export default GenericNoteTimeline;

View File

@ -265,3 +265,15 @@ export const LiveStreamIcon = createIcon({
d: "M16 4C16.5523 4 17 4.44772 17 5V9.2L22.2133 5.55071C22.4395 5.39235 22.7513 5.44737 22.9096 5.6736C22.9684 5.75764 23 5.85774 23 5.96033V18.0397C23 18.3158 22.7761 18.5397 22.5 18.5397C22.3974 18.5397 22.2973 18.5081 22.2133 18.4493L17 14.8V19C17 19.5523 16.5523 20 16 20H2C1.44772 20 1 19.5523 1 19V5C1 4.44772 1.44772 4 2 4H16ZM15 6H3V18H15V6ZM7.4 8.82867C7.47607 8.82867 7.55057 8.85036 7.61475 8.8912L11.9697 11.6625C12.1561 11.7811 12.211 12.0284 12.0924 12.2148C12.061 12.2641 12.0191 12.306 11.9697 12.3375L7.61475 15.1088C7.42837 15.2274 7.18114 15.1725 7.06254 14.9861C7.02169 14.9219 7 14.8474 7 14.7713V9.22867C7 9.00776 7.17909 8.82867 7.4 8.82867ZM21 8.84131L17 11.641V12.359L21 15.1587V8.84131Z",
defaultProps,
});
export const ImageGridTimelineIcon = createIcon({
displayName: "ImageGridTimelineIcon",
d: "M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM11 13H4V19H11V13ZM20 13H13V19H20V13ZM11 5H4V11H11V5ZM20 5H13V11H20V5Z",
defaultProps,
});
export const TextTimelineIcon = createIcon({
displayName: "ImageGridTimeline",
d: "M8 4H21V6H8V4ZM4.5 6.5C3.67157 6.5 3 5.82843 3 5C3 4.17157 3.67157 3.5 4.5 3.5C5.32843 3.5 6 4.17157 6 5C6 5.82843 5.32843 6.5 4.5 6.5ZM4.5 13.5C3.67157 13.5 3 12.8284 3 12C3 11.1716 3.67157 10.5 4.5 10.5C5.32843 10.5 6 11.1716 6 12C6 12.8284 5.32843 13.5 4.5 13.5ZM4.5 20.4C3.67157 20.4 3 19.7284 3 18.9C3 18.0716 3.67157 17.4 4.5 17.4C5.32843 17.4 6 18.0716 6 18.9C6 19.7284 5.32843 20.4 4.5 20.4ZM8 11H21V13H8V11ZM8 18H21V20H8V18Z",
defaultProps,
});

View File

@ -1,8 +1,8 @@
import { Box, CardProps, Code, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import { Box, CardProps, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import useOpenGraphData from "../hooks/use-open-graph-data";
export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<CardProps, "children">) {
const { value: data, loading } = useOpenGraphData(url);
const { value: data } = useOpenGraphData(url);
const link = (
<Link href={url.toString()} isExternal color="blue.500">
@ -14,14 +14,12 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
return (
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
{data.ogImage?.map((ogImage) => (
<Image key={ogImage.url} src={ogImage.url} mx="auto" />
))}
{data.ogImage?.length === 1 && <Image key={data.ogImage[0].url} src={data.ogImage[0].url} mx="auto" />}
<Box m="2" mt="4">
<Heading size="sm" my="2">
<LinkOverlay href={url.toString()} isExternal>
{data.ogTitle ?? data.dcTitle}
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
</LinkOverlay>
</Heading>
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>

View File

@ -1,11 +1,12 @@
import { SettingsIcon } from "@chakra-ui/icons";
import { Avatar, Button, Flex, Heading, LinkOverlay, Text, VStack } from "@chakra-ui/react";
import { Avatar, Button, Flex, Heading, IconButton, LinkOverlay, Text, VStack } from "@chakra-ui/react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import accountService from "../../services/account";
import { ConnectedRelays } from "../connected-relays";
import {
ChatIcon,
EditIcon,
FeedIcon,
LiveStreamIcon,
LogoutIcon,
@ -16,10 +17,13 @@ import {
} from "../icons";
import ProfileLink from "./profile-link";
import AccountSwitcher from "./account-switcher";
import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
export default function DesktopSideNav() {
const navigate = useNavigate();
const account = useCurrentAccount();
const { openModal } = useContext(PostModalContext);
return (
<VStack width="15rem" pt="2" alignItems="stretch" flexShrink={0}>
@ -65,6 +69,18 @@ export default function DesktopSideNav() {
</Text>
)}
<ConnectedRelays />
<Flex justifyContent="flex-end" py="8">
<IconButton
icon={<EditIcon />}
aria-label="New post"
w="4rem"
h="4rem"
fontSize="1.5rem"
borderRadius="50%"
colorScheme="brand"
onClick={() => openModal()}
/>
</Flex>
</VStack>
);
}

View File

@ -0,0 +1,15 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { RelayIcon } from "../icons";
import { useRelaySelectionContext } from "../../providers/relay-selection-provider";
export default function RelaySelectionButton({ ...props }: ButtonProps) {
const { openModal, relays } = useRelaySelectionContext();
return (
<>
<Button leftIcon={<RelayIcon />} onClick={openModal} {...props}>
{relays.length} {relays.length === 1 ? "Relay" : "Relays"}
</Button>
</>
);
}

View File

@ -15,8 +15,8 @@ import {
useToast,
} from "@chakra-ui/react";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { RelayFavicon } from "../../components/relay-favicon";
import { RelayUrlInput } from "../../components/relay-url-input";
import { RelayFavicon } from "../relay-favicon";
import { RelayUrlInput } from "../relay-url-input";
import { normalizeRelayUrl } from "../../helpers/url";
import { unique } from "../../helpers/array";
import relayScoreboardService from "../../services/relay-scoreboard";
@ -63,7 +63,7 @@ export default function RelaySelectionModal({
const relays = useReadRelayUrls([...selected, ...newSelected, ...Array.from(manuallyAddedRelays)]);
return (
<Modal isOpen={true} onClose={onClose} closeOnOverlayClick={false}>
<Modal isOpen={true} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Select Relays</ModalHeader>
@ -108,7 +108,7 @@ export default function RelaySelectionModal({
onClose();
}}
>
Save
Set relays
</Button>
</ModalFooter>
</ModalContent>

View File

@ -0,0 +1,37 @@
import React from "react";
import useSubject from "../../../hooks/use-subject";
import { TimelineLoader } from "../../../classes/timeline-loader";
import RepostNote from "./repost-note";
import { Note } from "../../note";
import { NostrEvent } from "../../../types/nostr-event";
import { Text } from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import { STREAM_KIND } from "../../../helpers/nostr/stream";
import StreamNote from "./stream-note";
const RenderEvent = React.memo(({ event }: { event: NostrEvent }) => {
switch (event.kind) {
case Kind.Text:
return <Note event={event} maxHeight={1200} />;
case Kind.Repost:
return <RepostNote event={event} maxHeight={1200} />;
case STREAM_KIND:
return <StreamNote event={event} />;
default:
return <Text>Unknown event kind: {event.kind}</Text>;
}
});
const GenericNoteTimeline = React.memo(({ timeline }: { timeline: TimelineLoader }) => {
const notes = useSubject(timeline.timeline);
return (
<>
{notes.map((note) => (
<RenderEvent key={note.id} event={note} />
))}
</>
);
});
export default GenericNoteTimeline;

View File

@ -1,19 +1,19 @@
import { useRef } from "react";
import { Flex, Heading, SkeletonText, Text } from "@chakra-ui/react";
import { useAsync } from "react-use";
import singleEventService from "../services/single-event";
import { isETag, NostrEvent } from "../types/nostr-event";
import { ErrorFallback } from "./error-boundary";
import { Note } from "./note";
import { NoteMenu } from "./note/note-menu";
import { UserAvatar } from "./user-avatar";
import { UserDnsIdentityIcon } from "./user-dns-identity-icon";
import { UserLink } from "./user-link";
import { TrustProvider } from "../providers/trust";
import { safeJson } from "../helpers/parse";
import singleEventService from "../../../services/single-event";
import { isETag, NostrEvent } from "../../../types/nostr-event";
import { ErrorFallback } from "../../error-boundary";
import { Note } from "../../note";
import { NoteMenu } from "../../note/note-menu";
import { UserAvatar } from "../../user-avatar";
import { UserDnsIdentityIcon } from "../../user-dns-identity-icon";
import { UserLink } from "../../user-link";
import { TrustProvider } from "../../../providers/trust";
import { safeJson } from "../../../helpers/parse";
import { verifySignature } from "nostr-tools";
import { useReadRelayUrls } from "../hooks/use-client-relays";
import { useRegisterIntersectionEntity } from "../providers/intersection-observer";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
function parseHardcodedNoteContent(event: NostrEvent): NostrEvent | null {
const json = safeJson(event.content, null);

View File

@ -0,0 +1,75 @@
import { useMemo, useRef } from "react";
import {
Badge,
Box,
Card,
CardBody,
CardFooter,
CardProps,
Divider,
Flex,
Heading,
Image,
LinkBox,
LinkOverlay,
Spacer,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import dayjs from "dayjs";
import { NostrEvent } from "../../../types/nostr-event";
import { parseStreamEvent } from "../../../helpers/nostr/stream";
import useEventNaddr from "../../../hooks/use-event-naddr";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { UserAvatar } from "../../user-avatar";
import { UserLink } from "../../user-link";
import StreamStatusBadge from "../../../views/streams/components/status-badge";
import { NoteRelays } from "../../note/note-relays";
export default function StreamNote({ event, ...props }: CardProps & { event: NostrEvent }) {
const stream = useMemo(() => parseStreamEvent(event), [event]);
const { title, image } = stream;
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
const naddr = useEventNaddr(event);
return (
<Card {...props} ref={ref}>
<LinkBox as={CardBody} p="2" display="flex" flexDirection="column" gap="2">
<Flex gap="2">
<Flex gap="2" direction="column">
<Flex gap="2" alignItems="center">
<UserAvatar pubkey={stream.host} size="sm" noProxy />
<Heading size="sm">
<UserLink pubkey={stream.host} />
</Heading>
</Flex>
{image && <Image src={image} alt={title} borderRadius="lg" maxH="15rem" />}
<Heading size="md">
<LinkOverlay as={RouterLink} to={`/streams/${naddr}`}>
{title}
</LinkOverlay>
</Heading>
</Flex>
</Flex>
{stream.tags.length > 0 && (
<Flex gap="2" wrap="wrap">
{stream.tags.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
</Flex>
)}
<Text>Updated: {dayjs.unix(stream.updated).fromNow()}</Text>
</LinkBox>
<Divider />
<CardFooter p="2" display="flex" gap="2" alignItems="center">
<StreamStatusBadge stream={stream} />
<Spacer />
<NoteRelays event={stream.event} />
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,65 @@
import { useCallback, useRef } from "react";
import { Flex, Grid } from "@chakra-ui/react";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import GenericNoteTimeline from "./generic-note-timeline";
import { ImageGalleryProvider } from "../image-gallery";
import MediaTimeline from "./media-timeline";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { TimelineLoader } from "../../classes/timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "./timeline-action-and-status";
import { useSearchParams } from "react-router-dom";
import { NostrEvent } from "../../types/nostr-event";
import { matchImageUrls } from "../../helpers/regexp";
export function useTimelinePageEventFilter() {
const [params, setParams] = useSearchParams();
const view = params.get("view");
return useCallback(
(event: NostrEvent) => {
if (view === "images" && !event.content.match(matchImageUrls)) return false;
return true;
},
[view]
);
}
export type TimelineViewType = "timeline" | "images";
export default function TimelinePage({ timeline, header }: { timeline: TimelineLoader; header?: React.ReactNode }) {
const isMobile = useIsMobile();
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
const [params, setParams] = useSearchParams();
const mode = (params.get("view") as TimelineViewType) ?? "timeline";
const renderTimeline = () => {
switch (mode) {
case "timeline":
return <GenericNoteTimeline timeline={timeline} />;
case "images":
return (
<ImageGalleryProvider>
<Grid templateColumns={`repeat(${isMobile ? 2 : 5}, 1fr)`} gap="4">
<MediaTimeline timeline={timeline} />
</Grid>
</ImageGalleryProvider>
);
default:
return null;
}
};
return (
<IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
{header}
{renderTimeline()}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
}

View File

@ -0,0 +1,69 @@
import React, { useMemo, useRef } from "react";
import { TimelineLoader } from "../../../classes/timeline-loader";
import useSubject from "../../../hooks/use-subject";
import { matchImageUrls } from "../../../helpers/regexp";
import { ImageGalleryLink, ImageGalleryProvider } from "../../image-gallery";
import { Box, Grid, IconButton } from "@chakra-ui/react";
import { useIsMobile } from "../../../hooks/use-is-mobile";
import { useNavigate } from "react-router-dom";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getSharableNoteId } from "../../../helpers/nip19";
import { ExternalLinkIcon } from "../../icons";
const matchAllImages = new RegExp(matchImageUrls, "ig");
type ImagePreview = { eventId: string; src: string; index: number };
const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
const navigate = useNavigate();
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, image.eventId);
return (
<ImageGalleryLink href={image.src} position="relative" ref={ref}>
<Box aspectRatio={1} backgroundImage={`url(${image.src})`} backgroundSize="cover" backgroundPosition="center" />
<IconButton
icon={<ExternalLinkIcon />}
aria-label="Open note"
position="absolute"
right="2"
top="2"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/n/${getSharableNoteId(image.eventId)}`);
}}
/>
</ImageGalleryLink>
);
});
export default function MediaTimeline({ timeline }: { timeline: TimelineLoader }) {
const isMobile = useIsMobile();
const events = useSubject(timeline.timeline);
const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = [];
for (const event of events) {
const urls = event.content.matchAll(matchAllImages);
let i = 0;
for (const url of urls) {
images.push({ eventId: event.id, src: url[0], index: i++ });
}
}
return images;
}, [events]);
return (
<ImageGalleryProvider>
{images.map((image) => (
<ImagePreview key={image.eventId + "-" + image.index} image={image} />
))}
</ImageGalleryProvider>
);
}

View File

@ -1,6 +1,6 @@
import { Alert, AlertIcon, Button, Spinner } from "@chakra-ui/react";
import { TimelineLoader } from "../classes/timeline-loader";
import useSubject from "../hooks/use-subject";
import { TimelineLoader } from "../../classes/timeline-loader";
import useSubject from "../../hooks/use-subject";
export default function TimelineActionAndStatus({ timeline }: { timeline: TimelineLoader }) {
const loading = useSubject(timeline.loading);
@ -20,7 +20,7 @@ export default function TimelineActionAndStatus({ timeline }: { timeline: Timeli
}
return (
<Button onClick={() => timeline.loadMore()} flexShrink={0} size="lg" mx="auto" minW="lg">
<Button onClick={() => timeline.loadMore()} flexShrink={0} size="lg" mx="auto" colorScheme="brand" my="4">
Load More
</Button>
);

View File

@ -0,0 +1,30 @@
import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react";
import { ImageGridTimelineIcon, TextTimelineIcon } from "../icons";
import { TimelineViewType } from "./index";
import { useSearchParams } from "react-router-dom";
export default function TimelineViewTypeButtons(props: ButtonGroupProps) {
const [params, setParams] = useSearchParams();
const mode = (params.get("view") as TimelineViewType) ?? "timeline";
const onChange = (type: TimelineViewType) => {
setParams({ view: type }, { replace: true });
};
return (
<ButtonGroup>
<IconButton
aria-label="Timeline"
icon={<TextTimelineIcon />}
variant={mode === "timeline" ? "solid" : "outline"}
onClick={() => onChange("timeline")}
/>
<IconButton
aria-label="Image grid"
icon={<ImageGridTimelineIcon />}
variant={mode === "images" ? "solid" : "outline"}
onClick={() => onChange("images")}
/>
</ButtonGroup>
);
}

View File

@ -2,11 +2,14 @@ import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
import { unique } from "../array";
export const STREAM_KIND = 30311;
export const STREAM_CHAT_MESSAGE_KIND = 1311;
export type ParsedStream = {
event: NostrEvent;
author: string;
host: string;
title: string;
title?: string;
summary?: string;
image?: string;
updated: number;
@ -30,7 +33,6 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream {
const startTime = starts ? parseInt(starts) : stream.created_at;
const endTime = endsTag ? parseInt(endsTag) : dayjs(startTime).add(4, "hour").unix();
if (!title) throw new Error("missing title");
if (!identifier) throw new Error("missing identifier");
if (!streaming) throw new Error("missing streaming");
@ -73,7 +75,7 @@ export function buildChatMessage(stream: ParsedStream, content: string) {
tags: [["a", getATag(stream), "", "root"]],
content,
created_at: dayjs().unix(),
kind: 1311,
kind: STREAM_CHAT_MESSAGE_KIND,
};
return template;

View File

@ -0,0 +1,23 @@
import { useMemo } from "react";
import { NostrEvent } from "../types/nostr-event";
import { nip19 } from "nostr-tools";
import { getEventRelays } from "../services/event-relays";
import relayScoreboardService from "../services/relay-scoreboard";
export default function useEventNaddr(event: NostrEvent) {
return useMemo(() => {
const identifier = event.tags.find((t) => t[0] === "d" && t[1])?.[1];
const relays = getEventRelays(event.id).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
if (!identifier) return null;
return nip19.naddrEncode({
identifier,
relays: onlyTwo,
pubkey: event.pubkey,
kind: event.kind,
});
}, [event]);
}

View File

@ -0,0 +1,15 @@
import { useEffect, useRef } from "react";
import { usePrevious } from "react-use";
export default function useRelaysChanged(relays: string[], cb: (relays: string[]) => void) {
const callback = useRef(cb);
callback.current = cb;
const prev = usePrevious(relays);
useEffect(() => {
if (!!prev && prev?.join(",") !== relays.join(",")) {
// always call the latest callback
callback.current(relays);
}
}, [relays.join(",")]);
}

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { useUnmount } from "react-use";
import { NostrQuery } from "../types/nostr-query";
import { NostrRequestFilter } from "../types/nostr-query";
import { NostrEvent } from "../types/nostr-event";
import timelineCacheService from "../services/timeline-cache";
@ -10,7 +10,7 @@ type Options = {
cursor?: number;
};
export function useTimelineLoader(key: string, relays: string[], query: NostrQuery, opts?: Options) {
export function useTimelineLoader(key: string, relays: string[], query: NostrRequestFilter, opts?: Options) {
const timeline = useMemo(() => timelineCacheService.createTimeline(key), [key]);
useEffect(() => {

View File

@ -100,10 +100,13 @@ export default function IntersectionObserverProvider<T = undefined>({
[elementIds]
);
const context = {
observer,
setElementId,
};
const context = useMemo(
() => ({
observer,
setElementId,
}),
[observer, setElementId]
);
return <IntersectionObserverContext.Provider value={context}>{children}</IntersectionObserverContext.Provider>;
}

View File

@ -0,0 +1,62 @@
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
import { useReadRelayUrls } from "../hooks/use-client-relays";
import { useDisclosure } from "@chakra-ui/react";
import RelaySelectionModal from "../components/relay-selection/relay-selection-modal";
import { unique } from "../helpers/array";
import { useLocation, useNavigate } from "react-router-dom";
type RelaySelectionContextType = {
relays: string[];
setSelected: (relays: string[]) => void;
openModal: () => void;
};
export const RelaySelectionContext = createContext<RelaySelectionContextType>({
relays: [],
setSelected: () => {},
openModal: () => {},
});
export function useRelaySelectionContext() {
return useContext(RelaySelectionContext);
}
export function useRelaySelectionRelays() {
return useContext(RelaySelectionContext).relays;
}
export type RelaySelectionProviderProps = PropsWithChildren & {
overrideDefault?: string[];
additionalDefaults?: string[];
};
export default function RelaySelectionProvider({
children,
overrideDefault,
additionalDefaults,
}: RelaySelectionProviderProps) {
const relaysModal = useDisclosure();
const { state } = useLocation();
const navigate = useNavigate();
const userReadRelays = useReadRelayUrls();
const relays = useMemo(() => {
if (state?.relays) return state.relays;
if (overrideDefault) return overrideDefault;
if (additionalDefaults) return unique([...userReadRelays, ...additionalDefaults]);
return userReadRelays;
}, [state?.relays, overrideDefault, userReadRelays, additionalDefaults]);
const setSelected = useCallback((relays: string[]) => {
navigate(".", { state: { relays }, replace: true });
}, []);
return (
<RelaySelectionContext.Provider value={{ relays, setSelected, openModal: relaysModal.onOpen }}>
{children}
{relaysModal.isOpen && (
<RelaySelectionModal selected={relays} onSubmit={setSelected} onClose={relaysModal.onClose} />
)}
</RelaySelectionContext.Provider>
);
}

View File

@ -94,7 +94,11 @@ class DirectMessagesService {
const account = accountService.current.value;
if (!account) return;
if (this.incomingSub.query?.since && dayjs.unix(this.incomingSub.query.since).isBefore(from)) {
if (
!Array.isArray(this.incomingSub.query) &&
this.incomingSub.query?.since &&
dayjs.unix(this.incomingSub.query.since).isBefore(from)
) {
// "since" is already set on the subscription and its older than "from"
return;
}

View File

@ -1,7 +1,7 @@
import { NostrEvent } from "./nostr-event";
export type NostrOutgoingEvent = ["EVENT", NostrEvent];
export type NostrOutgoingRequest = ["REQ", string, NostrQuery];
export type NostrOutgoingRequest = ["REQ", string, ...NostrQuery[]];
export type NostrOutgoingClose = ["CLOSE", string];
export type NostrOutgoingMessage = NostrOutgoingEvent | NostrOutgoingRequest | NostrOutgoingClose;
@ -19,3 +19,5 @@ export type NostrQuery = {
until?: number;
limit?: number;
};
export type NostrRequestFilter = NostrQuery | NostrQuery[];

View File

@ -1,138 +0,0 @@
import { Box, Button, Card, CardBody, CardProps, Flex, IconButton, Spacer, Text, Textarea } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useParams } from "react-router-dom";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { ArrowLeftSIcon } from "../../components/icons";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { normalizeToHex } from "../../helpers/nip19";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useIsMobile } from "../../hooks/use-is-mobile";
import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import directMessagesService, { getMessageRecipient } from "../../services/direct-messages";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderGenericUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
import RequireCurrentAccount from "../../providers/require-current-account";
function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
let content: EmbedableContent = [text];
content = embedNostrLinks(content);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
return <Box whiteSpace="pre-wrap">{content}</Box>;
}
function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwnMessage = account.pubkey === event.pubkey;
return (
<Flex direction="column">
<Text size="sm" textAlign={isOwnMessage ? "right" : "left"} px="2">
{dayjs.unix(event.created_at).fromNow()}
</Text>
<Card size="sm" mr={isOwnMessage ? 0 : "8"} ml={isOwnMessage ? "8" : 0}>
<CardBody position="relative">
<DecryptPlaceholder
data={event.content}
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
>
{(text) => <MessageContent event={event} text={text} />}
</DecryptPlaceholder>
</CardBody>
</Card>
</Flex>
);
}
function DirectMessageChatPage() {
const { key } = useParams();
if (!key) return <Navigate to="/" />;
const pubkey = normalizeToHex(key);
if (!pubkey) throw new Error("invalid pubkey");
const { requestEncrypt, requestSignature } = useSigningContext();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [from, setFrom] = useState(dayjs().subtract(1, "week"));
const [content, setContent] = useState<string>("");
useEffect(() => directMessagesService.loadDateRange(from), [from]);
const loadMore = () => {
setLoading(true);
setFrom((date) => dayjs(date).subtract(1, "week"));
setTimeout(() => {
setLoading(false);
}, 1000);
};
const subject = useMemo(() => directMessagesService.getUserMessages(pubkey), [pubkey]);
const messages = useSubject(subject);
const sendMessage = async () => {
if (!content) return;
const encrypted = await requestEncrypt(content, pubkey);
if (!encrypted) return;
const event: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: encrypted,
tags: [["p", pubkey]],
created_at: dayjs().unix(),
};
const signed = await requestSignature(event);
if (!signed) return;
const writeRelays = clientRelaysService.getWriteUrls();
nostrPostAction(writeRelays, signed);
setContent("");
};
return (
<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={isMobile ? "sm" : "md"}
/>
<UserAvatar pubkey={pubkey} size={isMobile ? "sm" : "md"} />
<UserLink pubkey={pubkey} />
</CardBody>
</Card>
<Flex flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="4" py="4">
<Spacer height="100vh" />
<Button onClick={loadMore} mx="auto" flexShrink={0} isLoading={loading}>
Load More
</Button>
{[...messages].reverse().map((event) => (
<Message key={event.id} event={event} />
))}
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
</Flex>
</Flex>
);
}
export default function DirectMessageChatView() {
return (
<RequireCurrentAccount>
<DirectMessageChatPage />
</RequireCurrentAccount>
);
}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import {
Button,
ButtonGroup,
Editable,
EditableInput,
@ -9,6 +9,7 @@ import {
FormLabel,
IconButton,
Input,
Spacer,
Switch,
useDisclosure,
useEditableControls,
@ -16,18 +17,15 @@ import {
import { CloseIcon } from "@chakra-ui/icons";
import { useNavigate, useParams } from "react-router-dom";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isReply } from "../../helpers/nostr-event";
import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import RelaySelectionModal from "./relay-selection-modal";
import { CheckIcon, EditIcon } from "../../components/icons";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generic-note-timeline";
import { unique } from "../../helpers/array";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useRelaysChanged from "../../hooks/use-relays-changed";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@ -42,7 +40,7 @@ function EditableControls() {
);
}
export default function HashTagView() {
function HashTagPage() {
const navigate = useNavigate();
const { hashtag } = useParams() as { hashtag: string };
const [editableHashtag, setEditableHashtag] = useState(hashtag);
@ -50,93 +48,65 @@ export default function HashTagView() {
useAppTitle("#" + hashtag);
const defaultRelays = useReadRelayUrls();
const [selectedRelays, setSelectedRelays] = useState(defaultRelays);
// add the default relays to the selection when they load
useEffect(() => {
setSelectedRelays((a) => unique([...a, ...defaultRelays]));
}, [defaultRelays.join("|")]);
const relaysModal = useDisclosure();
const readRelays = useRelaySelectionRelays();
const { isOpen: showReplies, onToggle } = useDisclosure();
const timelinePageEventFilter = useTimelinePageEventFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
return showReplies ? true : !isReply(event);
if (!showReplies && isReply(event)) return false;
return timelinePageEventFilter(event);
},
[showReplies]
);
const timeline = useTimelineLoader(
`${hashtag}-hashtag`,
selectedRelays,
readRelays,
{ kinds: [1], "#t": [hashtag] },
{ eventFilter }
);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
useRelaysChanged(readRelays, () => timeline.reset());
const header = (
<Flex gap="4" alignItems="center" wrap="wrap" pr="2">
<Editable
value={editableHashtag}
onChange={(v) => setEditableHashtag(v)}
fontSize="3xl"
fontWeight="bold"
display="flex"
gap="2"
alignItems="center"
selectAllOnFocus
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
flexShrink={0}
>
<div>
#<EditablePreview p={0} />
</div>
<Input as={EditableInput} maxW="md" />
<EditableControls />
</Editable>
<RelaySelectionButton />
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
<Spacer />
<TimelineViewTypeButtons />
</Flex>
);
return <TimelinePage timeline={timeline} header={header} />;
}
export default function HashTagView() {
return (
<>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex
direction="column"
gap="4"
overflowY="auto"
overflowX="hidden"
flex={1}
pb="4"
pt="4"
pl="1"
pr="1"
ref={scrollBox}
>
<Flex gap="4" alignItems="center" wrap="wrap">
<Editable
value={editableHashtag}
onChange={(v) => setEditableHashtag(v)}
fontSize="3xl"
fontWeight="bold"
display="flex"
gap="2"
alignItems="center"
selectAllOnFocus
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
flexShrink={0}
>
<div>
#<EditablePreview p={0} />
</div>
<Input as={EditableInput} maxW="md" />
<EditableControls />
</Editable>
<Button leftIcon={<RelayIcon />} onClick={relaysModal.onOpen}>
{selectedRelays.length} Relays
</Button>
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
</Flex>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
{relaysModal.isOpen && (
<RelaySelectionModal
selected={selectedRelays}
onSubmit={(relays) => {
setSelectedRelays(relays);
timeline.forgetEvents();
}}
onClose={relaysModal.onClose}
/>
)}
</>
<RelaySelectionProvider>
<HashTagPage />
</RelaySelectionProvider>
);
}

View File

@ -1,24 +1,19 @@
import { Button, Flex, FormControl, FormLabel, Switch } from "@chakra-ui/react";
import { Flex, FormControl, FormLabel, Switch } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom";
import { isReply, truncatedId } from "../../helpers/nostr-event";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useUserContacts } from "../../hooks/use-user-contacts";
import { AddIcon } from "@chakra-ui/icons";
import { useCallback, useContext, useRef } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import { useCallback } from "react";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account";
import RequireCurrentAccount from "../../providers/require-current-account";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generic-note-timeline";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
function FollowingTabBody() {
const account = useCurrentAccount()!;
const readRelays = useReadRelayUrls();
const { openModal } = useContext(PostModalContext);
const contacts = useUserContacts(account.pubkey, readRelays);
const [search, setSearch] = useSearchParams();
const showReplies = search.has("replies");
@ -26,12 +21,13 @@ function FollowingTabBody() {
showReplies ? setSearch({}) : setSearch({ replies: "show" });
};
const timelinePageEventFilter = useTimelinePageEventFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
return true;
return timelinePageEventFilter(event);
},
[showReplies]
[showReplies, timelinePageEventFilter]
);
const following = contacts?.contacts || [];
@ -42,34 +38,19 @@ function FollowingTabBody() {
{ enabled: following.length > 0, eventFilter }
);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<Button
variant="outline"
leftIcon={<AddIcon />}
onClick={() => openModal()}
isDisabled={account.readonly}
flexShrink={0}
>
New Post
</Button>
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
</FormControl>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
const header = (
<Flex px="2">
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
</FormControl>
<TimelineViewTypeButtons />
</Flex>
);
return <TimelinePage timeline={timeline} header={header} />;
}
export default function FollowingTab() {

View File

@ -1,77 +1,53 @@
import { useCallback, useRef } from "react";
import { Flex, FormControl, FormLabel, Select, Switch, useDisclosure } from "@chakra-ui/react";
import { useSearchParams } from "react-router-dom";
import { unique } from "../../helpers/array";
import { useCallback } from "react";
import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react";
import { isReply } from "../../helpers/nostr-event";
import { useAppTitle } from "../../hooks/use-app-title";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generic-note-timeline";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useRelaysChanged from "../../hooks/use-relays-changed";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
export default function GlobalTab() {
useAppTitle("global");
const defaultRelays = useReadRelayUrls();
const [searchParams, setSearchParams] = useSearchParams();
const selectedRelay = searchParams.get("relay") ?? "";
const setSelectedRelay = (url: string) => {
if (url) {
setSearchParams({ relay: url });
} else setSearchParams({});
};
function GlobalPage() {
const readRelays = useRelaySelectionRelays();
const { isOpen: showReplies, onToggle } = useDisclosure();
const availableRelays = unique([...defaultRelays, selectedRelay]).filter(Boolean);
useAppTitle("global");
const timelineEventFilter = useTimelinePageEventFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
return true;
return timelineEventFilter(event);
},
[showReplies]
[showReplies, timelineEventFilter]
);
const timeline = useTimelineLoader(
[`global`, selectedRelay].join(","),
selectedRelay ? [selectedRelay] : [],
{ kinds: [1] },
{ eventFilter }
const timeline = useTimelineLoader(`global`, readRelays, { kinds: [1] }, { eventFilter });
useRelaysChanged(readRelays, () => timeline.reset());
const header = (
<Flex gap="2" pr="2">
<RelaySelectionButton />
<FormControl display="flex" alignItems="center">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
<TimelineViewTypeButtons />
</Flex>
);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return <TimelinePage timeline={timeline} header={header} />;
}
export default function GlobalTab() {
// wrap the global page with another relay selection so it dose not effect the rest of the app
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex py="4" direction="column" gap="2" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<Flex gap="2">
<Select
placeholder="Select Relay"
maxWidth="250"
value={selectedRelay}
onChange={(e) => {
setSelectedRelay(e.target.value);
}}
>
{availableRelays.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</Select>
<FormControl display="flex" alignItems="center">
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
</FormControl>
</Flex>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
<RelaySelectionProvider overrideDefault={["wss://welcome.nostr.wine"]}>
<GlobalPage />
</RelaySelectionProvider>
);
}

112
src/views/messages/chat.tsx Normal file
View File

@ -0,0 +1,112 @@
import { Button, Card, CardBody, Flex, IconButton, Textarea } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useRef, useState } from "react";
import { Link, Navigate, useParams } from "react-router-dom";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { ArrowLeftSIcon } from "../../components/icons";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { normalizeToHex } from "../../helpers/nip19";
import { useIsMobile } from "../../hooks/use-is-mobile";
import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import { DraftNostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account";
import { Message } from "./message";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { truncatedId } from "../../helpers/nostr-event";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
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";
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const isMobile = useIsMobile();
const account = useCurrentAccount()!;
const { requestEncrypt, requestSignature } = useSigningContext();
const [content, setContent] = useState<string>("");
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(`${truncatedId(pubkey)}-${truncatedId(account.pubkey)}-messages`, readRelays, [
{
kinds: [Kind.EncryptedDirectMessage],
"#p": [account.pubkey],
authors: [pubkey],
},
{
kinds: [Kind.EncryptedDirectMessage],
"#p": [pubkey],
authors: [account.pubkey],
},
]);
const messages = useSubject(timeline.timeline);
const sendMessage = async () => {
if (!content) return;
const encrypted = await requestEncrypt(content, pubkey);
if (!encrypted) return;
const event: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: encrypted,
tags: [["p", pubkey]],
created_at: dayjs().unix(),
};
const signed = await requestSignature(event);
if (!signed) return;
const writeRelays = clientRelaysService.getWriteUrls();
nostrPostAction(writeRelays, signed);
setContent("");
};
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider root={scrollBox} 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={isMobile ? "sm" : "md"}
/>
<UserAvatar pubkey={pubkey} size={isMobile ? "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} />
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
</Flex>
</Flex>
</IntersectionObserverProvider>
);
}
export default function DirectMessageChatView() {
const { key } = useParams();
if (!key) return <Navigate to="/" />;
const pubkey = normalizeToHex(key);
if (!pubkey) throw new Error("invalid pubkey");
return (
<RequireCurrentAccount>
<DirectMessageChatPage pubkey={pubkey} />
</RequireCurrentAccount>
);
}

View File

@ -14,7 +14,7 @@ import {
Text,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { UserAvatar } from "../../components/user-avatar";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";

View File

@ -0,0 +1,51 @@
import { Box, Card, CardBody, CardHeader, CardProps, Flex, Heading, Text } from "@chakra-ui/react";
import dayjs from "dayjs";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { getMessageRecipient } from "../../services/direct-messages";
import { NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderGenericUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
import { useRef } from "react";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
export function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
let content: EmbedableContent = [text];
content = embedNostrLinks(content);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
return <Box whiteSpace="pre-wrap">{content}</Box>;
}
export function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwnMessage = account.pubkey === event.pubkey;
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
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>
<Text ml="auto">{dayjs.unix(event.created_at).fromNow()}</Text>
</CardHeader>
<CardBody position="relative">
<DecryptPlaceholder
data={event.content}
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
>
{(text) => <MessageContent event={event} text={text} />}
</DecryptPlaceholder>
</CardBody>
</Card>
</Flex>
);
}

View File

@ -9,7 +9,7 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { NoteLink } from "../../components/note-link";
import RequireCurrentAccount from "../../providers/require-current-account";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import useSubject from "../../hooks/use-subject";
import { truncatedId } from "../../helpers/nostr-event";

View File

@ -36,6 +36,7 @@ import RawValue from "../../../components/debug-modals/raw-value";
import RawJson from "../../../components/debug-modals/raw-json";
import { NoteRelays } from "../../../components/note/note-relays";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import useEventNaddr from "../../../hooks/use-event-naddr";
export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) {
const { title, identifier, image } = stream;
@ -45,18 +46,7 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, stream.event.id);
const naddr = useMemo(() => {
const relays = getEventRelays(stream.event.id).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
return nip19.naddrEncode({
identifier,
relays: onlyTwo,
pubkey: stream.author,
kind: stream.event.kind,
});
}, [identifier]);
const naddr = useEventNaddr(stream.event);
return (
<>

View File

@ -1,18 +1,19 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Flex, Select } from "@chakra-ui/react";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { useCallback, useMemo, useRef, useState } from "react";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject";
import StreamCard from "./components/stream-card";
import { ParsedStream, getATag, parseStreamEvent } from "../../helpers/nostr/stream";
import { ParsedStream, STREAM_KIND, getATag, parseStreamEvent } from "../../helpers/nostr/stream";
import { NostrEvent } from "../../types/nostr-event";
import { RelayIconStack } from "../../components/relay-icon-stack";
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
import RelaySelectionProvider, { useRelaySelectionRelays } from "../../providers/relay-selection-provider";
import useRelaysChanged from "../../hooks/use-relays-changed";
export default function LiveStreamsTab() {
function StreamsPage() {
// hard code damus and snort relays for finding streams
const readRelays = useReadRelayUrls(["wss://relay.damus.io", "wss://relay.snort.social"]);
const readRelays = useRelaySelectionRelays(); //useReadRelayUrls(["wss://relay.damus.io", "wss://relay.snort.social"]);
const [filterStatus, setFilterStatus] = useState<string>("live");
const eventFilter = useCallback(
@ -25,7 +26,11 @@ export default function LiveStreamsTab() {
},
[filterStatus]
);
const timeline = useTimelineLoader(`streams`, readRelays, { kinds: [30311] }, { eventFilter });
const timeline = useTimelineLoader(`streams`, readRelays, { kinds: [STREAM_KIND] }, { eventFilter });
useRelaysChanged(readRelays, () => timeline.reset());
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
@ -46,10 +51,13 @@ export default function LiveStreamsTab() {
return (
<Flex p="2" gap="2" overflow="hidden" direction="column">
<Select maxW="sm" value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
<option value="live">Live</option>
<option value="ended">Ended</option>
</Select>
<Flex gap="2">
<Select maxW="sm" value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)}>
<option value="live">Live</option>
<option value="ended">Ended</option>
</Select>
<RelaySelectionButton ml="auto" />
</Flex>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex gap="2" wrap="wrap" overflowY="auto" overflowX="hidden" ref={scrollBox}>
{streams.map((stream) => (
@ -60,3 +68,12 @@ export default function LiveStreamsTab() {
</Flex>
);
}
export default function StreamsView() {
return (
<RelaySelectionProvider
additionalDefaults={["wss://nos.lol", "wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"]}
>
<StreamsPage />
</RelaySelectionProvider>
);
}

View File

@ -5,7 +5,7 @@ import { Link as RouterLink, useParams, Navigate, useSearchParams } from "react-
import { nip19 } from "nostr-tools";
import { Global, css } from "@emotion/react";
import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream";
import { ParsedStream, STREAM_KIND, parseStreamEvent } from "../../../helpers/nostr/stream";
import { NostrRequest } from "../../../classes/nostr-request";
import { useReadRelayUrls } from "../../../hooks/use-client-relays";
import { unique } from "../../../helpers/array";
@ -138,7 +138,7 @@ export default function StreamView() {
try {
const parsed = nip19.decode(naddr);
if (parsed.type !== "naddr") throw new Error("Invalid stream address");
if (parsed.data.kind !== 30311) throw new Error("Invalid stream kind");
if (parsed.data.kind !== STREAM_KIND) throw new Error("Invalid stream kind");
const request = new NostrRequest(unique([...readRelays, ...(parsed.data.relays ?? [])]));
request.onEvent.subscribe((event) => {

View File

@ -1,4 +1,4 @@
import { useMemo } from "react";
import React, { useMemo } from "react";
import { EmbedableContent, embedUrls } from "../../../../helpers/embeds";
import {
embedEmoji,
@ -11,7 +11,7 @@ import {
import EmbeddedContent from "../../../../components/embeded-content";
import { NostrEvent } from "../../../../types/nostr-event";
export default function ChatMessageContent({ event }: { event: NostrEvent }) {
const ChatMessageContent = React.memo(({ event }: { event: NostrEvent }) => {
const content = useMemo(() => {
let c: EmbedableContent = [event.content];
@ -27,4 +27,6 @@ export default function ChatMessageContent({ event }: { event: NostrEvent }) {
}, [event.content]);
return <EmbeddedContent content={content} />;
}
});
export default ChatMessageContent;

View File

@ -15,9 +15,9 @@ function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStrea
return (
<TrustProvider event={event}>
<Box>
<Box ref={ref}>
<NoteZapButton note={event} size="xs" variant="ghost" float="right" ml="2" allowComment={false} />
<Text ref={ref}>
<Text>
<UserAvatar pubkey={event.pubkey} size="xs" display="inline-block" mr="2" />
<Text as="span" fontWeight="bold" color={event.pubkey === stream.host ? "rgb(248, 56, 217)" : "cyan"}>
<UserLink pubkey={event.pubkey} />

View File

@ -14,7 +14,7 @@ import {
useToast,
} from "@chakra-ui/react";
import { ParsedStream, buildChatMessage, getATag } from "../../../../helpers/nostr/stream";
import { ParsedStream, STREAM_CHAT_MESSAGE_KIND, buildChatMessage, getATag } from "../../../../helpers/nostr/stream";
import { useAdditionalRelayContext } from "../../../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../../../hooks/use-client-relays";
import { useUserRelays } from "../../../../hooks/use-user-relays";
@ -37,8 +37,8 @@ import { useTimelineLoader } from "../../../../hooks/use-timeline-loader";
import { truncatedId } from "../../../../helpers/nostr-event";
import { css } from "@emotion/react";
import TopZappers from "./top-zappers";
import { Kind } from "nostr-tools";
import { parseZapEvent } from "../../../../helpers/zaps";
import { Kind } from "nostr-tools";
const hideScrollbar = css`
scrollbar-width: 0;
@ -65,7 +65,7 @@ export default function StreamChat({
const timeline = useTimelineLoader(`${truncatedId(stream.event.id)}-chat`, readRelays, {
"#a": [getATag(stream)],
kinds: [1311, 9735],
kinds: [STREAM_CHAT_MESSAGE_KIND, Kind.Zap],
});
const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at);
@ -132,7 +132,7 @@ export default function StreamChat({
css={isChatLog && hideScrollbar}
>
{events.map((event) =>
event.kind === 1311 ? (
event.kind === STREAM_CHAT_MESSAGE_KIND ? (
<ChatMessage key={event.id} event={event} stream={stream} />
) : (
<ZapMessage key={event.id} zap={event} stream={stream} />

View File

@ -1,4 +1,4 @@
import { useRef } from "react";
import React, { useRef } from "react";
import { Box, Flex, Text } from "@chakra-ui/react";
import { ParsedStream } from "../../../../helpers/nostr/stream";
import { UserAvatar } from "../../../../components/user-avatar";
@ -11,7 +11,7 @@ import { readablizeSats } from "../../../../helpers/bolt11";
import { TrustProvider } from "../../../../providers/trust";
import ChatMessageContent from "./chat-message-content";
export default function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) {
function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, zap.id);
@ -34,3 +34,6 @@ export default function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: P
</TrustProvider>
);
}
const ZapMessageMemo = React.memo(ZapMessage);
export default ZapMessageMemo;

View File

@ -1,21 +1,16 @@
import { Flex, Heading, SkeletonText, Text, Link, IconButton, Spacer } from "@chakra-ui/react";
import { Flex, Heading, IconButton, Spacer } from "@chakra-ui/react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { CopyIconButton } from "../../../components/copy-icon-button";
import { ChatIcon, EditIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
import { QrIconButton } from "./share-qr-button";
import { ChatIcon, EditIcon } from "../../../components/icons";
import { UserAvatar } from "../../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { UserFollowButton } from "../../../components/user-follow-button";
import { UserTipButton } from "../../../components/user-tip-button";
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip19";
import { truncatedId } from "../../../helpers/nostr-event";
import { fixWebsiteUrl, getUserDisplayName } from "../../../helpers/user-metadata";
import { getUserDisplayName } from "../../../helpers/user-metadata";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import { useIsMobile } from "../../../hooks/use-is-mobile";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { UserProfileMenu } from "./user-profile-menu";
import { embedUrls } from "../../../helpers/embeds";
import { renderGenericUrl } from "../../../components/embed-types";
export default function Header({
pubkey,
@ -45,6 +40,7 @@ export default function Header({
aria-label="Edit profile"
title="Edit profile"
size="sm"
colorScheme="brand"
onClick={() => navigate("/profile")}
/>
)}

View File

@ -43,7 +43,7 @@ import Header from "./components/header";
const tabs = [
{ label: "About", path: "about" },
{ label: "Notes", path: "notes" },
{ label: "Media", path: "media" },
{ label: "Streams", path: "streams" },
{ label: "Zaps", path: "zaps" },
{ label: "Following", path: "following" },
{ label: "Likes", path: "likes" },

View File

@ -7,7 +7,7 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";

View File

@ -1,100 +0,0 @@
import React, { useCallback, useMemo, useRef } from "react";
import { Box, Flex, Grid, IconButton } from "@chakra-ui/react";
import { useNavigate, useOutletContext } from "react-router-dom";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { matchImageUrls } from "../../helpers/regexp";
import { useIsMobile } from "../../hooks/use-is-mobile";
import { ImageGalleryLink, ImageGalleryProvider } from "../../components/image-gallery";
import { ExternalLinkIcon } from "../../components/icons";
import { getSharableNoteId } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { truncatedId } from "../../helpers/nostr-event";
type ImagePreview = { eventId: string; src: string; index: number };
const matchAllImages = new RegExp(matchImageUrls, "ig");
const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
const navigate = useNavigate();
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, image.eventId);
return (
<ImageGalleryLink href={image.src} position="relative" ref={ref}>
<Box aspectRatio={1} backgroundImage={`url(${image.src})`} backgroundSize="cover" backgroundPosition="center" />
<IconButton
icon={<ExternalLinkIcon />}
aria-label="Open note"
position="absolute"
right="2"
top="2"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/n/${getSharableNoteId(image.eventId)}`);
}}
/>
</ImageGalleryLink>
);
});
const UserMediaTab = () => {
const isMobile = useIsMobile();
const { pubkey } = useOutletContext() as { pubkey: string };
const contextRelays = useAdditionalRelayContext();
const eventFilter = useCallback((e: NostrEvent) => e.kind === 1 && !!e.content.match(matchAllImages), []);
const timeline = useTimelineLoader(
truncatedId(pubkey) + "-notes",
contextRelays,
{
authors: [pubkey],
kinds: [1, 6],
},
{ eventFilter }
);
const events = useSubject(timeline.timeline);
const images = useMemo(() => {
var images: { eventId: string; src: string; index: number }[] = [];
for (const event of events) {
const urls = event.content.matchAll(matchAllImages);
let i = 0;
for (const url of urls) {
images.push({ eventId: event.id, src: url[0], index: i++ });
}
}
return images;
}, [events]);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<Flex direction="column" gap="2" px="2" pb="8" h="full" overflowY="auto" ref={scrollBox}>
<ImageGalleryProvider>
<Grid templateColumns={`repeat(${isMobile ? 2 : 5}, 1fr)`} gap="4">
{images.map((image) => (
<ImagePreview key={image.eventId + "-" + image.index} image={image} />
))}
</Grid>
</ImageGalleryProvider>
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
};
export default UserMediaTab;

View File

@ -1,64 +1,61 @@
import { useCallback, useRef } from "react";
import { Flex, FormControl, FormLabel, Switch, useDisclosure } from "@chakra-ui/react";
import { useCallback } from "react";
import { Flex, FormControl, FormLabel, Spacer, Switch, useDisclosure } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { Kind } from "nostr-tools";
import { isReply, isRepost, truncatedId } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { RelayIconStack } from "../../components/relay-icon-stack";
import { NostrEvent } from "../../types/nostr-event";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/generic-note-timeline";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { STREAM_KIND } from "../../helpers/nostr/stream";
import TimelineViewType from "../../components/timeline-page/timeline-view-type";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
const UserNotesTab = () => {
export default function UserNotesTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
const timelineEventFilter = useTimelinePageEventFilter();
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showReplies && isReply(event)) return false;
if (hideReposts && isRepost(event)) return false;
return true;
return timelineEventFilter(event);
},
[showReplies, hideReposts]
[showReplies, hideReposts, timelineEventFilter]
);
const timeline = useTimelineLoader(
truncatedId(pubkey) + "-notes",
readRelays,
{
authors: [pubkey],
kinds: [1, 6],
kinds: [Kind.Text, Kind.Repost, STREAM_KIND],
},
{ eventFilter }
);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<FormControl display="flex" alignItems="center" mx="2">
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0">
Replies
</FormLabel>
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
<FormLabel htmlFor="reposts" mb="0">
Reposts
</FormLabel>
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
</FormControl>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
const header = (
<Flex gap="2" px="2">
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0">
Replies
</FormLabel>
</FormControl>
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
<FormLabel htmlFor="reposts" mb="0">
Reposts
</FormLabel>
</FormControl>
<Spacer />
<RelayIconStack relays={readRelays} direction="row-reverse" maxRelays={4} />
<TimelineViewType />
</Flex>
);
};
export default UserNotesTab;
return <TimelinePage header={header} timeline={timeline} />;
}

View File

@ -6,7 +6,7 @@ import { filterTagsByContentRefs, truncatedId } from "../../helpers/nostr-event"
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { isETag, isPTag, NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
function ReportEvent({ report }: { report: NostrEvent }) {

View File

@ -0,0 +1,36 @@
import { useRef } from "react";
import { Flex } from "@chakra-ui/react";
import { useOutletContext } from "react-router-dom";
import { truncatedId } from "../../helpers/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import GenericNoteTimeline from "../../components/timeline-page/generic-note-timeline";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { STREAM_KIND } from "../../helpers/nostr/stream";
export default function UserStreamsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const timeline = useTimelineLoader(truncatedId(pubkey) + "-streams", readRelays, [
{
authors: [pubkey],
kinds: [STREAM_KIND],
},
{ "#p": [pubkey], kinds: [STREAM_KIND] },
]);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
);
}

View File

@ -14,7 +14,7 @@ import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import useSubject from "../../hooks/use-subject";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";