Standardize timeline rendering between views

This commit is contained in:
hzrd149 2023-07-06 23:45:49 -05:00
parent bdc1c98d78
commit d4a8110fda
24 changed files with 337 additions and 295 deletions

View File

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

View File

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

View File

@ -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";
@ -29,8 +28,6 @@ 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";
@ -78,7 +75,6 @@ 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 /> },

View File

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

View File

@ -1,12 +1,12 @@
import React from "react";
import useSubject from "../../hooks/use-subject";
import { TimelineLoader } from "../../classes/timeline-loader";
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 { 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 { STREAM_KIND } from "../../../helpers/nostr/stream";
import StreamNote from "./stream-note";
const RenderEvent = React.memo(({ event }: { event: NostrEvent }) => {

View File

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

View File

@ -17,14 +17,14 @@ import {
} 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";
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]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,21 +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 "../../components/relay-selection/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/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";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
function EditableControls() {
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
@ -56,9 +51,11 @@ function HashTagPage() {
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]
);
@ -71,58 +68,39 @@ function HashTagPage() {
useRelaysChanged(readRelays, () => timeline.reset());
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
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>
<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>
</Flex>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
</>
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() {

View File

@ -3,15 +3,13 @@ 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 { useCallback, useRef } from "react";
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/timeline/generic-note-timeline";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
function FollowingTabBody() {
const account = useCurrentAccount()!;
@ -23,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 || [];
@ -39,25 +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}>
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
</FormControl>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
const header = (
<Flex px="2">
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="show-replies" mb="0">
Show Replies
</FormLabel>
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} />
</FormControl>
<TimelineViewTypeButtons />
</Flex>
);
return <TimelinePage timeline={timeline} header={header} />;
}
export default function FollowingTab() {

View File

@ -1,16 +1,14 @@
import { useCallback, useRef } from "react";
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 { 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/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";
import TimelinePage, { useTimelinePageEventFilter } from "../../components/timeline-page";
import TimelineViewTypeButtons from "../../components/timeline-page/timeline-view-type";
function GlobalPage() {
const readRelays = useRelaySelectionRelays();
@ -18,39 +16,33 @@ function GlobalPage() {
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`, readRelays, { kinds: [1] }, { eventFilter });
useRelaysChanged(readRelays, () => timeline.reset());
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}>
<Flex gap="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>
</Flex>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
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>
);
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 (

View File

@ -21,7 +21,7 @@ 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-action-and-status";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const isMobile = useIsMobile();

View File

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

View File

@ -43,7 +43,6 @@ 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" },

View File

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

View File

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

View File

@ -1,32 +1,31 @@
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/timeline/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",
@ -38,29 +37,25 @@ const UserNotesTab = () => {
{ eventFilter }
);
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
<FormControl display="flex" alignItems="center" mx="2">
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0">
Replies
</FormLabel>
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
<FormLabel htmlFor="reposts" mb="0">
Reposts
</FormLabel>
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
</FormControl>
<GenericNoteTimeline timeline={timeline} />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
</IntersectionObserverProvider>
const header = (
<Flex gap="2" px="2">
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="replies" mr="2" isChecked={showReplies} onChange={toggleReplies} />
<FormLabel htmlFor="replies" mb="0">
Replies
</FormLabel>
</FormControl>
<FormControl display="flex" alignItems="center" w="auto">
<Switch id="reposts" mr="2" isChecked={!hideReposts} onChange={toggleReposts} />
<FormLabel htmlFor="reposts" mb="0">
Reposts
</FormLabel>
</FormControl>
<Spacer />
<RelayIconStack relays={readRelays} direction="row-reverse" maxRelays={4} />
<TimelineViewType />
</Flex>
);
};
export default UserNotesTab;
return <TimelinePage header={header} timeline={timeline} />;
}

View File

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

View File

@ -3,10 +3,10 @@ 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 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/generic-note-timeline";
import GenericNoteTimeline from "../../components/timeline-page/generic-note-timeline";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { STREAM_KIND } from "../../helpers/nostr/stream";

View File

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