mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-11 21:29:26 +02:00
Merge branch 'next'
This commit is contained in:
commit
655ab6f6f2
5
.changeset/chilly-keys-design.md
Normal file
5
.changeset/chilly-keys-design.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Standardize timeline rendering between views
|
5
.changeset/friendly-seals-sin.md
Normal file
5
.changeset/friendly-seals-sin.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Fix preformance bug with large timelines
|
5
.changeset/green-jars-exercise.md
Normal file
5
.changeset/green-jars-exercise.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add more prominent new post button
|
5
.changeset/selfish-pants-design.md
Normal file
5
.changeset/selfish-pants-design.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Rebuild direct message chat view using timeline loader
|
5
.changeset/slimy-pandas-check.md
Normal file
5
.changeset/slimy-pandas-check.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Dont show multiple images on open-graph link card
|
20
src/app.tsx
20
src/app.tsx
@ -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 /> },
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
@ -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,
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
15
src/components/relay-selection/relay-selection-button.tsx
Normal file
15
src/components/relay-selection/relay-selection-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
37
src/components/timeline-page/generic-note-timeline/index.tsx
Normal file
37
src/components/timeline-page/generic-note-timeline/index.tsx
Normal 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;
|
@ -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);
|
@ -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>
|
||||
);
|
||||
}
|
65
src/components/timeline-page/index.tsx
Normal file
65
src/components/timeline-page/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
69
src/components/timeline-page/media-timeline/index.tsx
Normal file
69
src/components/timeline-page/media-timeline/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
30
src/components/timeline-page/timeline-view-type.tsx
Normal file
30
src/components/timeline-page/timeline-view-type.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
23
src/hooks/use-event-naddr.ts
Normal file
23
src/hooks/use-event-naddr.ts
Normal 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]);
|
||||
}
|
15
src/hooks/use-relays-changed.ts
Normal file
15
src/hooks/use-relays-changed.ts
Normal 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(",")]);
|
||||
}
|
@ -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(() => {
|
||||
|
@ -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>;
|
||||
}
|
||||
|
62
src/providers/relay-selection-provider.tsx
Normal file
62
src/providers/relay-selection-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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[];
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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
112
src/views/messages/chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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";
|
51
src/views/messages/message.tsx
Normal file
51
src/views/messages/message.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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";
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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;
|
||||
|
@ -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} />
|
||||
|
@ -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} />
|
||||
|
@ -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;
|
||||
|
@ -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")}
|
||||
/>
|
||||
)}
|
||||
|
@ -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" },
|
||||
|
@ -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";
|
||||
|
@ -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;
|
@ -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} />;
|
||||
}
|
||||
|
@ -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 }) {
|
||||
|
36
src/views/user/streams.tsx
Normal file
36
src/views/user/streams.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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";
|
||||
|
Loading…
x
Reference in New Issue
Block a user