From facb287433424ace2667f193d8aa1f7fe15a52cb Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 5 Jul 2023 10:49:29 -0500 Subject: [PATCH 1/4] add more prominent new post button --- .changeset/green-jars-exercise.md | 5 +++++ src/components/page/desktop-side-nav.tsx | 18 +++++++++++++++++- src/views/home/following-tab.tsx | 16 ++-------------- .../stream-chat/chat-message-content.tsx | 8 +++++--- .../stream/stream-chat/chat-message.tsx | 4 ++-- src/views/streams/stream/stream-chat/index.tsx | 1 - .../streams/stream/stream-chat/zap-message.tsx | 7 +++++-- src/views/user/components/header.tsx | 12 ++++-------- 8 files changed, 40 insertions(+), 31 deletions(-) create mode 100644 .changeset/green-jars-exercise.md diff --git a/.changeset/green-jars-exercise.md b/.changeset/green-jars-exercise.md new file mode 100644 index 000000000..932bbf8ae --- /dev/null +++ b/.changeset/green-jars-exercise.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add more prominent new post button diff --git a/src/components/page/desktop-side-nav.tsx b/src/components/page/desktop-side-nav.tsx index 838beb3e5..43c304b84 100644 --- a/src/components/page/desktop-side-nav.tsx +++ b/src/components/page/desktop-side-nav.tsx @@ -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 ( @@ -65,6 +69,18 @@ export default function DesktopSideNav() { )} + + } + aria-label="New post" + w="4rem" + h="4rem" + fontSize="1.5rem" + borderRadius="50%" + colorScheme="brand" + onClick={() => openModal()} + /> + ); } diff --git a/src/views/home/following-tab.tsx b/src/views/home/following-tab.tsx index 9c57bcb3d..fc53f4cf9 100644 --- a/src/views/home/following-tab.tsx +++ b/src/views/home/following-tab.tsx @@ -1,11 +1,9 @@ -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, useRef } from "react"; import { useReadRelayUrls } from "../../hooks/use-client-relays"; import { useCurrentAccount } from "../../hooks/use-current-account"; import RequireCurrentAccount from "../../providers/require-current-account"; @@ -18,7 +16,6 @@ import GenericNoteTimeline from "../../components/generic-note-timeline"; 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"); @@ -48,15 +45,6 @@ function FollowingTabBody() { return ( - Show Replies diff --git a/src/views/streams/stream/stream-chat/chat-message-content.tsx b/src/views/streams/stream/stream-chat/chat-message-content.tsx index 84887b8a0..dbcc4a1b2 100644 --- a/src/views/streams/stream/stream-chat/chat-message-content.tsx +++ b/src/views/streams/stream/stream-chat/chat-message-content.tsx @@ -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 ; -} +}); + +export default ChatMessageContent; diff --git a/src/views/streams/stream/stream-chat/chat-message.tsx b/src/views/streams/stream/stream-chat/chat-message.tsx index 524dfd7db..54e4ac033 100644 --- a/src/views/streams/stream/stream-chat/chat-message.tsx +++ b/src/views/streams/stream/stream-chat/chat-message.tsx @@ -15,9 +15,9 @@ function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStrea return ( - + - + diff --git a/src/views/streams/stream/stream-chat/index.tsx b/src/views/streams/stream/stream-chat/index.tsx index b53ec3ca5..d622072d1 100644 --- a/src/views/streams/stream/stream-chat/index.tsx +++ b/src/views/streams/stream/stream-chat/index.tsx @@ -37,7 +37,6 @@ 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"; const hideScrollbar = css` diff --git a/src/views/streams/stream/stream-chat/zap-message.tsx b/src/views/streams/stream/stream-chat/zap-message.tsx index 18f27f31f..386517c8f 100644 --- a/src/views/streams/stream/stream-chat/zap-message.tsx +++ b/src/views/streams/stream/stream-chat/zap-message.tsx @@ -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(null); useRegisterIntersectionEntity(ref, zap.id); @@ -34,3 +34,6 @@ export default function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: P ); } + +const ZapMessageMemo = React.memo(ZapMessage); +export default ZapMessageMemo; diff --git a/src/views/user/components/header.tsx b/src/views/user/components/header.tsx index 66257066e..8ab8e66ab 100644 --- a/src/views/user/components/header.tsx +++ b/src/views/user/components/header.tsx @@ -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")} /> )} From e6b773980a85a331505c293a3a49d81d150d4479 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 5 Jul 2023 13:06:17 -0500 Subject: [PATCH 2/4] timeline and relay selection cleanup --- src/app.tsx | 6 +- src/classes/nostr-multi-subscription.ts | 20 +++-- src/classes/nostr-request.ts | 8 +- src/classes/nostr-subscription.ts | 16 ++-- src/classes/timeline-loader.ts | 29 +++++-- src/components/debug-modals/raw-value.tsx | 4 +- src/components/generic-note-timeline.tsx | 23 ------ .../relay-selection-button.tsx | 15 ++++ .../relay-selection-modal.tsx | 8 +- .../timeline/generic-note-timeline.tsx | 37 +++++++++ src/components/{ => timeline}/repost-note.tsx | 24 +++--- src/components/timeline/stream-note.tsx | 75 +++++++++++++++++++ src/helpers/nostr/stream.ts | 8 +- src/hooks/use-event-naddr.ts | 23 ++++++ src/hooks/use-relays-changed.ts | 15 ++++ src/hooks/use-timeline-loader.ts | 4 +- src/providers/relay-selection-provider.tsx | 62 +++++++++++++++ src/types/nostr-query.ts | 4 +- src/views/hashtag/index.tsx | 46 +++++------- src/views/home/following-tab.tsx | 2 +- src/views/home/global-tab.tsx | 56 +++++--------- src/views/streams/components/stream-card.tsx | 14 +--- src/views/streams/index.tsx | 39 +++++++--- src/views/streams/stream/index.tsx | 4 +- .../streams/stream/stream-chat/index.tsx | 7 +- src/views/user/index.tsx | 1 + src/views/user/notes.tsx | 6 +- src/views/user/streams.tsx | 36 +++++++++ 28 files changed, 425 insertions(+), 167 deletions(-) delete mode 100644 src/components/generic-note-timeline.tsx create mode 100644 src/components/relay-selection/relay-selection-button.tsx rename src/{views/hashtag => components/relay-selection}/relay-selection-modal.tsx (93%) create mode 100644 src/components/timeline/generic-note-timeline.tsx rename src/components/{ => timeline}/repost-note.tsx (73%) create mode 100644 src/components/timeline/stream-note.tsx create mode 100644 src/hooks/use-event-naddr.ts create mode 100644 src/hooks/use-relays-changed.ts create mode 100644 src/providers/relay-selection-provider.tsx create mode 100644 src/views/user/streams.tsx diff --git a/src/app.tsx b/src/app.tsx index 73281c4fd..37cb7a1d2 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -36,8 +36,9 @@ 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")); @@ -78,6 +79,7 @@ const router = createHashRouter([ { path: "about", element: }, { path: "notes", element: }, { path: "media", element: }, + { path: "streams", element: }, { path: "zaps", element: }, { path: "likes", element: }, { path: "followers", element: }, @@ -106,7 +108,7 @@ const router = createHashRouter([ }, { path: "streams", - element: , + element: , }, { path: "l/:link", element: }, { path: "t/:hashtag", element: }, diff --git a/src/classes/nostr-multi-subscription.ts b/src/classes/nostr-multi-subscription.ts index 5c52282e1..b6286547c 100644 --- a/src/classes/nostr-multi-subscription.ts +++ b/src/classes/nostr-multi-subscription.ts @@ -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(); seenEvents = new Set(); - 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]); } } } diff --git a/src/classes/nostr-request.ts b/src/classes/nostr-request.ts index ec7315f10..4ef8ae4d4 100644 --- a/src/classes/nostr-request.ts +++ b/src/classes/nostr-request.ts @@ -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); diff --git a/src/classes/nostr-subscription.ts b/src/classes/nostr-subscription.ts index c9ac5b00c..4a7981f3b 100644 --- a/src/classes/nostr-subscription.ts +++ b/src/classes/nostr-subscription.ts @@ -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(); onEOSE = new Subject(); - 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; } diff --git a/src/classes/timeline-loader.ts b/src/classes/timeline-loader.ts index 702ff22ec..24502a3ff 100644 --- a/src/classes/timeline-loader.ts +++ b/src/classes/timeline-loader.ts @@ -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(); onBlockFinish = new Subject(); - 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([]); @@ -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() { diff --git a/src/components/debug-modals/raw-value.tsx b/src/components/debug-modals/raw-value.tsx index ba0449f77..ebf55101d 100644 --- a/src/components/debug-modals/raw-value.tsx +++ b/src/components/debug-modals/raw-value.tsx @@ -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 ( @@ -11,7 +11,7 @@ export default function RawValue({ value, heading }: { heading: string; value: s {value} - + ); diff --git a/src/components/generic-note-timeline.tsx b/src/components/generic-note-timeline.tsx deleted file mode 100644 index f91f49ad3..000000000 --- a/src/components/generic-note-timeline.tsx +++ /dev/null @@ -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 ? ( - - ) : ( - - ) - )} - - ); -}); - -export default GenericNoteTimeline; diff --git a/src/components/relay-selection/relay-selection-button.tsx b/src/components/relay-selection/relay-selection-button.tsx new file mode 100644 index 000000000..df0393f6f --- /dev/null +++ b/src/components/relay-selection/relay-selection-button.tsx @@ -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 ( + <> + + + ); +} diff --git a/src/views/hashtag/relay-selection-modal.tsx b/src/components/relay-selection/relay-selection-modal.tsx similarity index 93% rename from src/views/hashtag/relay-selection-modal.tsx rename to src/components/relay-selection/relay-selection-modal.tsx index 23b531566..38e39e972 100644 --- a/src/views/hashtag/relay-selection-modal.tsx +++ b/src/components/relay-selection/relay-selection-modal.tsx @@ -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 ( - + Select Relays @@ -108,7 +108,7 @@ export default function RelaySelectionModal({ onClose(); }} > - Save + Set relays diff --git a/src/components/timeline/generic-note-timeline.tsx b/src/components/timeline/generic-note-timeline.tsx new file mode 100644 index 000000000..f798498fe --- /dev/null +++ b/src/components/timeline/generic-note-timeline.tsx @@ -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 ; + case Kind.Repost: + return ; + case STREAM_KIND: + return ; + default: + return Unknown event kind: {event.kind}; + } +}); + +const GenericNoteTimeline = React.memo(({ timeline }: { timeline: TimelineLoader }) => { + const notes = useSubject(timeline.timeline); + + return ( + <> + {notes.map((note) => ( + + ))} + + ); +}); + +export default GenericNoteTimeline; diff --git a/src/components/repost-note.tsx b/src/components/timeline/repost-note.tsx similarity index 73% rename from src/components/repost-note.tsx rename to src/components/timeline/repost-note.tsx index cb983a81f..75af44583 100644 --- a/src/components/repost-note.tsx +++ b/src/components/timeline/repost-note.tsx @@ -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); diff --git a/src/components/timeline/stream-note.tsx b/src/components/timeline/stream-note.tsx new file mode 100644 index 000000000..7c002365c --- /dev/null +++ b/src/components/timeline/stream-note.tsx @@ -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(null); + useRegisterIntersectionEntity(ref, event.id); + + const naddr = useEventNaddr(event); + + return ( + + + + + + + + + + + {image && {title}} + + + {title} + + + + + {stream.tags.length > 0 && ( + + {stream.tags.map((tag) => ( + {tag} + ))} + + )} + Updated: {dayjs.unix(stream.updated).fromNow()} + + + + + + + + + ); +} diff --git a/src/helpers/nostr/stream.ts b/src/helpers/nostr/stream.ts index 9911da2a1..206b175dc 100644 --- a/src/helpers/nostr/stream.ts +++ b/src/helpers/nostr/stream.ts @@ -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; diff --git a/src/hooks/use-event-naddr.ts b/src/hooks/use-event-naddr.ts new file mode 100644 index 000000000..7b88280fa --- /dev/null +++ b/src/hooks/use-event-naddr.ts @@ -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]); +} diff --git a/src/hooks/use-relays-changed.ts b/src/hooks/use-relays-changed.ts new file mode 100644 index 000000000..d7d8d235d --- /dev/null +++ b/src/hooks/use-relays-changed.ts @@ -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(",")]); +} diff --git a/src/hooks/use-timeline-loader.ts b/src/hooks/use-timeline-loader.ts index db8a01d4c..4b9875788 100644 --- a/src/hooks/use-timeline-loader.ts +++ b/src/hooks/use-timeline-loader.ts @@ -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(() => { diff --git a/src/providers/relay-selection-provider.tsx b/src/providers/relay-selection-provider.tsx new file mode 100644 index 000000000..aaff95aa9 --- /dev/null +++ b/src/providers/relay-selection-provider.tsx @@ -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({ + 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 ( + + {children} + + {relaysModal.isOpen && ( + + )} + + ); +} diff --git a/src/types/nostr-query.ts b/src/types/nostr-query.ts index 58052ee31..51fabedba 100644 --- a/src/types/nostr-query.ts +++ b/src/types/nostr-query.ts @@ -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[]; diff --git a/src/views/hashtag/index.tsx b/src/views/hashtag/index.tsx index 43d12399f..87ff52d25 100644 --- a/src/views/hashtag/index.tsx +++ b/src/views/hashtag/index.tsx @@ -21,13 +21,16 @@ 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 RelaySelectionModal from "../../components/relay-selection/relay-selection-modal"; 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 GenericNoteTimeline from "../../components/timeline/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"; function EditableControls() { const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls(); @@ -42,7 +45,7 @@ function EditableControls() { ); } -export default function HashTagView() { +function HashTagPage() { const navigate = useNavigate(); const { hashtag } = useParams() as { hashtag: string }; const [editableHashtag, setEditableHashtag] = useState(hashtag); @@ -50,15 +53,7 @@ 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 eventFilter = useCallback( @@ -69,11 +64,13 @@ export default function HashTagView() { ); const timeline = useTimelineLoader( `${hashtag}-hashtag`, - selectedRelays, + readRelays, { kinds: [1], "#t": [hashtag] }, { eventFilter } ); + useRelaysChanged(readRelays, () => timeline.reset()); + const scrollBox = useRef(null); const callback = useTimelineCurserIntersectionCallback(timeline); @@ -111,9 +108,7 @@ export default function HashTagView() { - + @@ -126,17 +121,14 @@ export default function HashTagView() { - - {relaysModal.isOpen && ( - { - setSelectedRelays(relays); - timeline.forgetEvents(); - }} - onClose={relaysModal.onClose} - /> - )} ); } + +export default function HashTagView() { + return ( + + + + ); +} diff --git a/src/views/home/following-tab.tsx b/src/views/home/following-tab.tsx index fc53f4cf9..e7e149753 100644 --- a/src/views/home/following-tab.tsx +++ b/src/views/home/following-tab.tsx @@ -11,7 +11,7 @@ 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 GenericNoteTimeline from "../../components/timeline/generic-note-timeline"; function FollowingTabBody() { const account = useCurrentAccount()!; diff --git a/src/views/home/global-tab.tsx b/src/views/home/global-tab.tsx index d30565394..e7caf2b96 100644 --- a/src/views/home/global-tab.tsx +++ b/src/views/home/global-tab.tsx @@ -1,30 +1,22 @@ 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 { 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 GenericNoteTimeline from "../../components/timeline/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"; -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 eventFilter = useCallback( (event: NostrEvent) => { @@ -33,12 +25,9 @@ export default function GlobalTab() { }, [showReplies] ); - const timeline = useTimelineLoader( - [`global`, selectedRelay].join(","), - selectedRelay ? [selectedRelay] : [], - { kinds: [1] }, - { eventFilter } - ); + const timeline = useTimelineLoader(`global`, readRelays, { kinds: [1] }, { eventFilter }); + + useRelaysChanged(readRelays, () => timeline.reset()); const scrollBox = useRef(null); const callback = useTimelineCurserIntersectionCallback(timeline); @@ -47,20 +36,7 @@ export default function GlobalTab() { - + @@ -75,3 +51,11 @@ export default function GlobalTab() { ); } +export default function GlobalTab() { + // wrap the global page with another relay selection so it dose not effect the rest of the app + return ( + + + + ); +} diff --git a/src/views/streams/components/stream-card.tsx b/src/views/streams/components/stream-card.tsx index c155348a5..b2079869c 100644 --- a/src/views/streams/components/stream-card.tsx +++ b/src/views/streams/components/stream-card.tsx @@ -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(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 ( <> diff --git a/src/views/streams/index.tsx b/src/views/streams/index.tsx index 3ba58e81f..e4eb59c68 100644 --- a/src/views/streams/index.tsx +++ b/src/views/streams/index.tsx @@ -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("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(null); const callback = useTimelineCurserIntersectionCallback(timeline); @@ -46,10 +51,13 @@ export default function LiveStreamsTab() { return ( - + + + + {streams.map((stream) => ( @@ -60,3 +68,12 @@ export default function LiveStreamsTab() { ); } +export default function StreamsView() { + return ( + + + + ); +} diff --git a/src/views/streams/stream/index.tsx b/src/views/streams/stream/index.tsx index 6991adac9..36c160220 100644 --- a/src/views/streams/stream/index.tsx +++ b/src/views/streams/stream/index.tsx @@ -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) => { diff --git a/src/views/streams/stream/stream-chat/index.tsx b/src/views/streams/stream/stream-chat/index.tsx index d622072d1..6616997d8 100644 --- a/src/views/streams/stream/stream-chat/index.tsx +++ b/src/views/streams/stream/stream-chat/index.tsx @@ -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"; @@ -38,6 +38,7 @@ import { truncatedId } from "../../../../helpers/nostr-event"; import { css } from "@emotion/react"; import TopZappers from "./top-zappers"; import { parseZapEvent } from "../../../../helpers/zaps"; +import { Kind } from "nostr-tools"; const hideScrollbar = css` scrollbar-width: 0; @@ -64,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); @@ -131,7 +132,7 @@ export default function StreamChat({ css={isChatLog && hideScrollbar} > {events.map((event) => - event.kind === 1311 ? ( + event.kind === STREAM_CHAT_MESSAGE_KIND ? ( ) : ( diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 0ef57dfb9..d1f519677 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -44,6 +44,7 @@ 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" }, diff --git a/src/views/user/notes.tsx b/src/views/user/notes.tsx index 3945a7ea8..6268832fc 100644 --- a/src/views/user/notes.tsx +++ b/src/views/user/notes.tsx @@ -1,6 +1,7 @@ import { useCallback, useRef } from "react"; import { Flex, FormControl, FormLabel, 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"; @@ -8,8 +9,9 @@ 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 GenericNoteTimeline from "../../components/timeline/generic-note-timeline"; import { useTimelineLoader } from "../../hooks/use-timeline-loader"; +import { STREAM_KIND } from "../../helpers/nostr/stream"; const UserNotesTab = () => { const { pubkey } = useOutletContext() as { pubkey: string }; @@ -31,7 +33,7 @@ const UserNotesTab = () => { readRelays, { authors: [pubkey], - kinds: [1, 6], + kinds: [Kind.Text, Kind.Repost, STREAM_KIND], }, { eventFilter } ); diff --git a/src/views/user/streams.tsx b/src/views/user/streams.tsx new file mode 100644 index 000000000..8c13d700d --- /dev/null +++ b/src/views/user/streams.tsx @@ -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-action-and-status"; +import IntersectionObserverProvider from "../../providers/intersection-observer"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import GenericNoteTimeline from "../../components/timeline/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(null); + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + root={scrollBox} callback={callback}> + + + + + + ); +} From bdc1c98d78c2cf513971574a2d9ecc43091e7586 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 5 Jul 2023 21:03:25 -0500 Subject: [PATCH 3/4] Rebuild direct message chat view using timeline loader --- .changeset/selfish-pants-design.md | 5 + .changeset/slimy-pandas-check.md | 5 + src/app.tsx | 4 +- src/components/open-graph-card.tsx | 10 +- src/services/direct-messages.ts | 6 +- src/views/dm/chat.tsx | 138 ------------------ src/views/messages/chat.tsx | 112 ++++++++++++++ .../{dm => messages}/decrypt-placeholder.tsx | 0 src/views/{dm => messages}/index.tsx | 2 +- src/views/messages/message.tsx | 51 +++++++ 10 files changed, 185 insertions(+), 148 deletions(-) create mode 100644 .changeset/selfish-pants-design.md create mode 100644 .changeset/slimy-pandas-check.md delete mode 100644 src/views/dm/chat.tsx create mode 100644 src/views/messages/chat.tsx rename src/views/{dm => messages}/decrypt-placeholder.tsx (100%) rename src/views/{dm => messages}/index.tsx (98%) create mode 100644 src/views/messages/message.tsx diff --git a/.changeset/selfish-pants-design.md b/.changeset/selfish-pants-design.md new file mode 100644 index 000000000..f8d7cebdd --- /dev/null +++ b/.changeset/selfish-pants-design.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Rebuild direct message chat view using timeline loader diff --git a/.changeset/slimy-pandas-check.md b/.changeset/slimy-pandas-check.md new file mode 100644 index 000000000..f8adf5176 --- /dev/null +++ b/.changeset/slimy-pandas-check.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +Dont show multiple images on open-graph link card diff --git a/src/app.tsx b/src/app.tsx index 37cb7a1d2..3340a5bed 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -25,8 +25,8 @@ 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"; diff --git a/src/components/open-graph-card.tsx b/src/components/open-graph-card.tsx index 99347758f..28fd2cc68 100644 --- a/src/components/open-graph-card.tsx +++ b/src/components/open-graph-card.tsx @@ -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) { - const { value: data, loading } = useOpenGraphData(url); + const { value: data } = useOpenGraphData(url); const link = ( @@ -14,14 +14,12 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit - {data.ogImage?.map((ogImage) => ( - - ))} + {data.ogImage?.length === 1 && } - {data.ogTitle ?? data.dcTitle} + {data.ogTitle?.trim() ?? data.dcTitle?.trim()} {data.ogDescription || data.dcDescription} diff --git a/src/services/direct-messages.ts b/src/services/direct-messages.ts index 512d7250b..43d7cd6fb 100644 --- a/src/services/direct-messages.ts +++ b/src/services/direct-messages.ts @@ -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; } diff --git a/src/views/dm/chat.tsx b/src/views/dm/chat.tsx deleted file mode 100644 index 4f9e16a03..000000000 --- a/src/views/dm/chat.tsx +++ /dev/null @@ -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 {content}; -} - -function Message({ event }: { event: NostrEvent } & Omit) { - const account = useCurrentAccount()!; - const isOwnMessage = account.pubkey === event.pubkey; - - return ( - - - {dayjs.unix(event.created_at).fromNow()} - - - - - {(text) => } - - - - - ); -} - -function DirectMessageChatPage() { - const { key } = useParams(); - if (!key) return ; - 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(""); - - 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 ( - - - - } - aria-label="Back" - to="/dm" - size={isMobile ? "sm" : "md"} - /> - - - - - - - - {[...messages].reverse().map((event) => ( - - ))} - - -