mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-26 17:52:18 +01:00
Merge branch 'next'
This commit is contained in:
commit
49d5f71258
5
.changeset/forty-balloons-destroy.md
Normal file
5
.changeset/forty-balloons-destroy.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Cleanup embed content (hopefully performance improvement)
|
5
.changeset/ninety-otters-nail.md
Normal file
5
.changeset/ninety-otters-nail.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
cache url open graph data
|
5
.changeset/orange-stingrays-shop.md
Normal file
5
.changeset/orange-stingrays-shop.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Remove twitter tweet embeds
|
5
.changeset/soft-flowers-teach.md
Normal file
5
.changeset/soft-flowers-teach.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
small fix for hashtags
|
5
.changeset/tasty-buckets-dream.md
Normal file
5
.changeset/tasty-buckets-dream.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": patch
|
||||
---
|
||||
|
||||
Performance improvements
|
5
.changeset/yellow-brooms-sell.md
Normal file
5
.changeset/yellow-brooms-sell.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add docker image
|
41
.github/workflows/docker-image.yml
vendored
Normal file
41
.github/workflows/docker-image.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
name: Create and publish a Docker image
|
||||
# copied from https://blog.pradumnasaraf.dev/publish-image-on-ghcr
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
9
dockerfile
Normal file
9
dockerfile
Normal file
@ -0,0 +1,9 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:18
|
||||
WORKDIR /app
|
||||
COPY . /app/
|
||||
RUN yarn install && yarn build
|
||||
|
||||
FROM nginx:stable-alpine-slim
|
||||
EXPOSE 80
|
||||
COPY --from=0 /app/dist /usr/share/nginx/html
|
@ -30,7 +30,5 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
|
||||
<script async src="/lib/twitter-widgets.js" charset="utf-8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
import React, { Suspense } from "react";
|
||||
import { createHashRouter, Outlet, RouterProvider, ScrollRestoration } from "react-router-dom";
|
||||
import { createHashRouter, Outlet, RouterProvider } from "react-router-dom";
|
||||
import { Spinner } from "@chakra-ui/react";
|
||||
import { ErrorBoundary } from "./components/error-boundary";
|
||||
import Layout from "./components/layout";
|
||||
@ -46,7 +46,6 @@ const RootPage = () => {
|
||||
return (
|
||||
<PageProviders>
|
||||
<Layout>
|
||||
<ScrollRestoration />
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
|
@ -54,7 +54,14 @@ const videoExt = [".mp4", ".mkv", ".webm", ".mov"];
|
||||
export function renderVideoUrl(match: URL) {
|
||||
if (!videoExt.some((ext) => match.pathname.endsWith(ext))) return null;
|
||||
|
||||
return <video src={match.toString()} controls style={{ maxWidth: "30rem", maxHeight: "20rem", width: "100%" }} />;
|
||||
return (
|
||||
<video
|
||||
key={match.href}
|
||||
src={match.toString()}
|
||||
controls
|
||||
style={{ maxWidth: "30rem", maxHeight: "20rem", width: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderGenericUrl(match: URL) {
|
||||
|
@ -20,22 +20,18 @@ export function embedNostrLinks(content: EmbedableContent) {
|
||||
|
||||
switch (decoded.type) {
|
||||
case "npub":
|
||||
return <UserLink color="blue.500" pubkey={decoded.data as string} showAt />;
|
||||
case "nprofile": {
|
||||
const pointer = decoded.data as ProfilePointer;
|
||||
return <UserLink color="blue.500" pubkey={pointer.pubkey} showAt />;
|
||||
}
|
||||
return <UserLink color="blue.500" pubkey={decoded.data} showAt />;
|
||||
case "nprofile":
|
||||
return <UserLink color="blue.500" pubkey={decoded.data.pubkey} showAt />;
|
||||
case "note":
|
||||
return <QuoteNote noteId={decoded.data as string} />;
|
||||
case "nevent": {
|
||||
const pointer = decoded.data as EventPointer;
|
||||
return <QuoteNote noteId={pointer.id} relay={pointer.relays?.[0]} />;
|
||||
}
|
||||
return <QuoteNote noteId={decoded.data} />;
|
||||
case "nevent":
|
||||
return <QuoteNote noteId={decoded.data.id} relays={decoded.data.relays} />;
|
||||
default:
|
||||
return match[0];
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
return match[0];
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -54,11 +50,11 @@ export function embedNostrMentions(content: EmbedableContent, event: NostrEvent
|
||||
return <UserLink color="blue.500" pubkey={tag[1]} showAt />;
|
||||
}
|
||||
if (tag[0] === "e" && tag[1]) {
|
||||
return <QuoteNote noteId={tag[1]} relay={tag[2]} />;
|
||||
return <QuoteNote noteId={tag[1]} relays={tag[2] ? [tag[2]] : undefined} />;
|
||||
}
|
||||
}
|
||||
|
||||
return match[0];
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -87,7 +83,7 @@ export function embedNostrHashtags(content: EmbedableContent, event: NostrEvent
|
||||
);
|
||||
}
|
||||
|
||||
return match[0];
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { replaceDomain } from "../../helpers/url";
|
||||
import appSettings from "../../services/app-settings";
|
||||
import { TweetEmbed } from "../tweet-embed";
|
||||
import { renderOpenGraphUrl } from "./common";
|
||||
|
||||
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js
|
||||
@ -11,5 +10,5 @@ export function renderTwitterUrl(match: URL) {
|
||||
|
||||
const { twitterRedirect } = appSettings.value;
|
||||
if (twitterRedirect) return renderOpenGraphUrl(replaceDomain(match, twitterRedirect));
|
||||
else return <TweetEmbed href={match.toString()} conversation={false} />;
|
||||
else return renderOpenGraphUrl(match);
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { AspectRatio, list } from "@chakra-ui/react";
|
||||
import appSettings from "../../services/app-settings";
|
||||
import { renderOpenGraphUrl } from "./common";
|
||||
import { replaceDomain } from "../../helpers/url";
|
||||
|
||||
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/youtube.js
|
||||
export const YOUTUBE_DOMAINS = [
|
||||
@ -21,6 +23,9 @@ export function renderYoutubeUrl(match: URL) {
|
||||
|
||||
const { youtubeRedirect } = appSettings.value;
|
||||
|
||||
// render opengraph card for performance
|
||||
// return renderOpenGraphUrl(youtubeRedirect ? replaceDomain(match, youtubeRedirect) : match);
|
||||
|
||||
if (match.pathname.startsWith("/playlist")) {
|
||||
const listId = match.searchParams.get("list");
|
||||
if (!listId) throw new Error("missing list id");
|
||||
@ -34,8 +39,7 @@ export function renderYoutubeUrl(match: URL) {
|
||||
src={embedUrl.toString()}
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
|
||||
width="100%"
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
@ -52,8 +56,7 @@ export function renderYoutubeUrl(match: URL) {
|
||||
src={embedUrl.toString()}
|
||||
title="YouTube video player"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
width="100%"
|
||||
></iframe>
|
||||
</AspectRatio>
|
||||
|
@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
import { EmbedableContent } from "../helpers/embeds";
|
||||
import { Text } from "@chakra-ui/react";
|
||||
|
||||
export default function EmbeddedContent({ content }: { content: EmbedableContent }) {
|
||||
return (
|
||||
<>
|
||||
{content.map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<Text as="span" key={"part-" + i}>
|
||||
{part}
|
||||
</Text>
|
||||
) : (
|
||||
React.cloneElement(part, { key: "part-" + i })
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -15,7 +15,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<ReloadPrompt mb="2" />
|
||||
<Container size="lg" display="flex" padding="0" gap="4" alignItems="flex-start">
|
||||
{!isMobile && <DesktopSideNav position="sticky" top="0" />}
|
||||
<Flex flexGrow={1} direction="column" w="full" overflowX="hidden" pb={isMobile ? "14" : 0}>
|
||||
<Flex flexGrow={1} direction="column" w="full" overflowX="hidden" overflowY="visible" pb={isMobile ? "14" : 0}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Flex>
|
||||
{isMobile && (
|
||||
|
@ -1,5 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Card, CardBody, CardHeader, Flex, Heading } from "@chakra-ui/react";
|
||||
import { Button, Card, CardBody, CardHeader, Spacer, useDisclosure } from "@chakra-ui/react";
|
||||
|
||||
import { NoteContents } from "./note-contents";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
@ -11,31 +11,29 @@ import appSettings from "../../services/app-settings";
|
||||
import EventVerificationIcon from "../event-verification-icon";
|
||||
import { TrustProvider } from "../../providers/trust";
|
||||
import { NoteLink } from "../note-link";
|
||||
import { ArrowDownSIcon, ArrowUpSIcon } from "../icons";
|
||||
|
||||
export default function EmbeddedNote({ note }: { note: NostrEvent }) {
|
||||
const { showSignatureVerification } = useSubject(appSettings);
|
||||
const expand = useDisclosure();
|
||||
|
||||
return (
|
||||
<TrustProvider event={note}>
|
||||
<Card variant="outline">
|
||||
<CardHeader padding="2">
|
||||
<Flex flex="1" gap="2" alignItems="center" wrap="wrap">
|
||||
<UserAvatarLink pubkey={note.pubkey} size="xs" />
|
||||
|
||||
<Heading size="sm" display="inline">
|
||||
<UserLink pubkey={note.pubkey} />
|
||||
</Heading>
|
||||
<UserDnsIdentityIcon pubkey={note.pubkey} onlyIcon />
|
||||
<Flex grow={1} />
|
||||
{showSignatureVerification && <EventVerificationIcon event={note} />}
|
||||
<NoteLink noteId={note.id} color="current" whiteSpace="nowrap">
|
||||
{dayjs.unix(note.created_at).fromNow()}
|
||||
</NoteLink>
|
||||
</Flex>
|
||||
<CardHeader padding="2" display="flex" gap="2" alignItems="center" flexWrap="wrap">
|
||||
<UserAvatarLink pubkey={note.pubkey} size="sm" />
|
||||
<UserLink pubkey={note.pubkey} fontWeight="bold" isTruncated fontSize="lg" />
|
||||
<UserDnsIdentityIcon pubkey={note.pubkey} onlyIcon />
|
||||
<Button size="sm" onClick={expand.onToggle} leftIcon={expand.isOpen ? <ArrowUpSIcon /> : <ArrowDownSIcon />}>
|
||||
Expand
|
||||
</Button>
|
||||
<Spacer />
|
||||
{showSignatureVerification && <EventVerificationIcon event={note} />}
|
||||
<NoteLink noteId={note.id} color="current" whiteSpace="nowrap">
|
||||
{dayjs.unix(note.created_at).fromNow()}
|
||||
</NoteLink>
|
||||
</CardHeader>
|
||||
<CardBody p="0">
|
||||
<NoteContents event={note} maxHeight={200} />
|
||||
</CardBody>
|
||||
<CardBody p="0">{expand.isOpen && <NoteContents event={note} />}</CardBody>
|
||||
</Card>
|
||||
</TrustProvider>
|
||||
);
|
||||
|
@ -38,10 +38,9 @@ import { useRegisterIntersectionEntity } from "../../providers/intersection-obse
|
||||
|
||||
export type NoteProps = {
|
||||
event: NostrEvent;
|
||||
maxHeight?: number;
|
||||
variant?: CardProps["variant"];
|
||||
};
|
||||
export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteProps) => {
|
||||
export const Note = React.memo(({ event, variant = "outline" }: NoteProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const { showReactions, showSignatureVerification } = useSubject(appSettings);
|
||||
|
||||
@ -59,10 +58,7 @@ export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteP
|
||||
<CardHeader padding="2">
|
||||
<Flex flex="1" gap="2" alignItems="center" wrap="wrap">
|
||||
<UserAvatarLink pubkey={event.pubkey} size={isMobile ? "xs" : "sm"} />
|
||||
|
||||
<Heading size="sm" display="inline">
|
||||
<UserLink pubkey={event.pubkey} />
|
||||
</Heading>
|
||||
<UserLink pubkey={event.pubkey} isTruncated fontWeight="bold" fontSize="lg" />
|
||||
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
|
||||
<Flex grow={1} />
|
||||
{showSignatureVerification && <EventVerificationIcon event={event} />}
|
||||
@ -72,7 +68,7 @@ export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteP
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody p="0">
|
||||
<NoteContentWithWarning event={event} maxHeight={maxHeight} />
|
||||
<NoteContentWithWarning event={event} />
|
||||
</CardBody>
|
||||
<CardFooter padding="2" display="flex" gap="2">
|
||||
<ButtonGroup size="sm" variant="link">
|
||||
|
@ -5,16 +5,12 @@ import { useExpand } from "./expanded";
|
||||
import SensitiveContentWarning from "../sensitive-content-warning";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
|
||||
export default function NoteContentWithWarning({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) {
|
||||
export default function NoteContentWithWarning({ event }: { event: NostrEvent }) {
|
||||
const expand = useExpand();
|
||||
const settings = useAppSettings();
|
||||
|
||||
const contentWarning = event.tags.find((t) => t[0] === "content-warning")?.[1];
|
||||
const showContentWarning = settings.showContentWarning && contentWarning && !expand?.expanded;
|
||||
|
||||
return showContentWarning ? (
|
||||
<SensitiveContentWarning description={contentWarning} />
|
||||
) : (
|
||||
<NoteContents event={event} maxHeight={maxHeight} />
|
||||
);
|
||||
return showContentWarning ? <SensitiveContentWarning description={contentWarning} /> : <NoteContents event={event} />;
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
import styled from "@emotion/styled";
|
||||
import { useExpand } from "./expanded";
|
||||
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
|
||||
import {
|
||||
embedLightningInvoice,
|
||||
@ -22,7 +20,6 @@ import {
|
||||
} from "../embed-types";
|
||||
import { ImageGalleryProvider } from "../image-gallery";
|
||||
import { renderRedditUrl } from "../embed-types/reddit";
|
||||
import EmbeddedContent from "../embeded-content";
|
||||
|
||||
function buildContents(event: NostrEvent | DraftNostrEvent) {
|
||||
let content: EmbedableContent = [event.content.trim()];
|
||||
@ -53,54 +50,17 @@ function buildContents(event: NostrEvent | DraftNostrEvent) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const GradientOverlay = styled.div`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0%) 0%, var(--chakra-colors-chakra-body-bg) 100%);
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export type NoteContentsProps = {
|
||||
event: NostrEvent | DraftNostrEvent;
|
||||
maxHeight?: number;
|
||||
};
|
||||
|
||||
export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps) => {
|
||||
export const NoteContents = React.memo(({ event }: NoteContentsProps) => {
|
||||
const content = buildContents(event);
|
||||
const expand = useExpand();
|
||||
const [innerHeight, setInnerHeight] = useState(0);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const testHeight = useCallback(() => {
|
||||
if (ref.current && maxHeight) {
|
||||
const rect = ref.current.getClientRects()[0];
|
||||
setInnerHeight(rect.height);
|
||||
}
|
||||
}, [maxHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
testHeight();
|
||||
}, [testHeight]);
|
||||
|
||||
const showOverlay = !!maxHeight && !expand?.expanded && innerHeight > maxHeight;
|
||||
|
||||
return (
|
||||
<ImageGalleryProvider>
|
||||
<Box
|
||||
whiteSpace="pre-wrap"
|
||||
maxHeight={!expand?.expanded ? maxHeight : undefined}
|
||||
position="relative"
|
||||
overflow={maxHeight && !expand?.expanded ? "hidden" : "initial"}
|
||||
onLoad={() => testHeight()}
|
||||
px="2"
|
||||
>
|
||||
<div ref={ref}>
|
||||
<EmbeddedContent content={content} />
|
||||
</div>
|
||||
{showOverlay && <GradientOverlay onClick={expand?.onExpand} />}
|
||||
<Box whiteSpace="pre-wrap" px="2">
|
||||
{content}
|
||||
</Box>
|
||||
</ImageGalleryProvider>
|
||||
);
|
||||
|
@ -3,9 +3,9 @@ import useSingleEvent from "../../hooks/use-single-event";
|
||||
import EmbeddedNote from "./embedded-note";
|
||||
import { NoteLink } from "../note-link";
|
||||
|
||||
const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => {
|
||||
const relays = useReadRelayUrls(relay ? [relay] : []);
|
||||
const { event, loading } = useSingleEvent(noteId, relays);
|
||||
const QuoteNote = ({ noteId, relays }: { noteId: string; relays?: string[] }) => {
|
||||
const readRelays = useReadRelayUrls(relays);
|
||||
const { event, loading } = useSingleEvent(noteId, readRelays);
|
||||
|
||||
return event ? <EmbeddedNote note={event} /> : <NoteLink noteId={noteId} />;
|
||||
};
|
||||
|
@ -14,7 +14,9 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
|
||||
|
||||
return (
|
||||
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
|
||||
{data.ogImage?.length === 1 && <Image key={data.ogImage[0].url} src={data.ogImage[0].url} mx="auto" />}
|
||||
{data.ogImage?.length === 1 && (
|
||||
<Image key={data.ogImage[0].url} src={new URL(data.ogImage[0].url, url).toString()} mx="auto" maxH="3in" />
|
||||
)}
|
||||
|
||||
<Box m="2" mt="4">
|
||||
<Heading size="sm" my="2">
|
||||
|
@ -12,9 +12,9 @@ import StreamNote from "./stream-note";
|
||||
const RenderEvent = React.memo(({ event }: { event: NostrEvent }) => {
|
||||
switch (event.kind) {
|
||||
case Kind.Text:
|
||||
return <Note event={event} maxHeight={1200} />;
|
||||
return <Note event={event} />;
|
||||
case Kind.Repost:
|
||||
return <RepostNote event={event} maxHeight={1200} />;
|
||||
return <RepostNote event={event} />;
|
||||
case STREAM_KIND:
|
||||
return <StreamNote event={event} />;
|
||||
default:
|
||||
|
@ -11,17 +11,19 @@ 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";
|
||||
|
||||
function parseHardcodedNoteContent(event: NostrEvent): NostrEvent | null {
|
||||
function parseHardcodedNoteContent(event: NostrEvent) {
|
||||
const json = safeJson(event.content, null);
|
||||
if (json) verifySignature(json);
|
||||
return null;
|
||||
|
||||
// TODO: disabled until signature verification can be done in another thread
|
||||
// if (json && !verifySignature(json)) return null;
|
||||
|
||||
return (json as NostrEvent) ?? null;
|
||||
}
|
||||
|
||||
export default function RepostNote({ event, maxHeight }: { event: NostrEvent; maxHeight?: number }) {
|
||||
export default function RepostNote({ event }: { event: NostrEvent }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useRegisterIntersectionEntity(ref, event.id);
|
||||
|
||||
@ -57,13 +59,7 @@ export default function RepostNote({ event, maxHeight }: { event: NostrEvent; ma
|
||||
</Text>
|
||||
<NoteMenu event={event} size="sm" variant="link" aria-label="note options" />
|
||||
</Flex>
|
||||
{loading ? (
|
||||
<SkeletonText />
|
||||
) : note ? (
|
||||
<Note event={note} maxHeight={maxHeight} />
|
||||
) : (
|
||||
<ErrorFallback error={error} />
|
||||
)}
|
||||
{loading ? <SkeletonText /> : note ? <Note event={note} /> : <ErrorFallback error={error} />}
|
||||
</Flex>
|
||||
</TrustProvider>
|
||||
);
|
||||
|
@ -30,7 +30,6 @@ 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();
|
||||
@ -54,8 +53,8 @@ export default function TimelinePage({ timeline, header }: { timeline: TimelineL
|
||||
}
|
||||
};
|
||||
return (
|
||||
<IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
|
||||
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
|
||||
<IntersectionObserverProvider<string> callback={callback}>
|
||||
<Flex direction="column" gap="2" pt="4" pb="8">
|
||||
{header}
|
||||
{renderTimeline()}
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
|
@ -9,18 +9,11 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
||||
import { getSharableNoteId } from "../../../helpers/nip19";
|
||||
import { ExternalLinkIcon } from "../../icons";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
const matchAllImages = new RegExp(matchImageUrls, "ig");
|
||||
|
||||
type ImagePreview = { eventId: string; src: string; index: number };
|
||||
|
||||
const StyledImageGalleryLink = styled(ImageGalleryLink)`
|
||||
&:not(:hover) > button {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -28,7 +21,7 @@ const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
|
||||
useRegisterIntersectionEntity(ref, image.eventId);
|
||||
|
||||
return (
|
||||
<StyledImageGalleryLink href={image.src} position="relative" ref={ref}>
|
||||
<ImageGalleryLink href={image.src} position="relative" ref={ref}>
|
||||
<Box aspectRatio={1} backgroundImage={`url(${image.src})`} backgroundSize="cover" backgroundPosition="center" />
|
||||
<IconButton
|
||||
icon={<ExternalLinkIcon />}
|
||||
@ -44,7 +37,7 @@ const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
|
||||
navigate(`/n/${getSharableNoteId(image.eventId)}`);
|
||||
}}
|
||||
/>
|
||||
</StyledImageGalleryLink>
|
||||
</ImageGalleryLink>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -1,30 +0,0 @@
|
||||
import { useColorMode } from "@chakra-ui/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type TweetEmbedProps = {
|
||||
href: string;
|
||||
conversation?: boolean;
|
||||
};
|
||||
|
||||
export const TweetEmbed = ({ href, conversation }: TweetEmbedProps) => {
|
||||
const ref = useRef<HTMLQuoteElement | null>(null);
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
// @ts-ignore
|
||||
window.twttr?.widgets.load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<blockquote
|
||||
className="twitter-tweet"
|
||||
ref={ref}
|
||||
data-conversation={conversation ? undefined : "none"}
|
||||
data-theme={colorMode}
|
||||
>
|
||||
<a href={href}></a>
|
||||
</blockquote>
|
||||
);
|
||||
};
|
@ -13,7 +13,7 @@ export const UserDnsIdentityIcon = ({ pubkey, onlyIcon }: { pubkey: string; only
|
||||
|
||||
const renderIcon = () => {
|
||||
if (loading) {
|
||||
return <Spinner size="xs" ml="1" />;
|
||||
return <Spinner size="xs" ml="1" title={metadata.nip05} />;
|
||||
} else if (error) {
|
||||
return <VerificationFailed color="yellow.500" />;
|
||||
} else if (!identity) {
|
||||
|
@ -26,15 +26,20 @@ export function embedJSX(content: EmbedableContent, embed: EmbedType): Embedable
|
||||
const { start, end } = (embed.getLocation || defaultGetLocation)(match);
|
||||
const before = subContent.slice(0, start);
|
||||
const after = subContent.slice(end, subContent.length);
|
||||
let embedRender = embed.render(match);
|
||||
let render = embed.render(match);
|
||||
|
||||
if (embedRender === null) return subContent;
|
||||
if (render === null) return subContent;
|
||||
|
||||
if (typeof embedRender !== "string" && !embedRender.props.key) {
|
||||
embedRender = cloneElement(embedRender, { key: embed.name + i });
|
||||
if (typeof render !== "string" && !render.props.key) {
|
||||
render = cloneElement(render, { key: match[0] });
|
||||
}
|
||||
|
||||
return [...embedJSX([before], embed), embedRender, ...embedJSX([after], embed)];
|
||||
const newContent: EmbedableContent = [];
|
||||
if (before.length > 0) newContent.push(...embedJSX([before], embed));
|
||||
newContent.push(render);
|
||||
if (after.length > 0) newContent.push(...embedJSX([after], embed));
|
||||
|
||||
return newContent;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,4 +3,4 @@ export const matchImageUrls =
|
||||
/https?:\/\/([\dA-z\.-]+\.[A-z\.]{2,12})((?:\/[\+~%\/\.\w\-_]*)?\.(?:svg|gif|png|jpg|jpeg|webp|avif))(\??(?:[\?#\-\+=&;%@\.\w_]*)#?(?:[\-\.\!\/\\\w]*))?/i;
|
||||
|
||||
export const matchNostrLink = /(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/i;
|
||||
export const matchHashtag = /(^|\s)#([^\s#]+)/i;
|
||||
export const matchHashtag = /(^|[^\p{L}])#([\p{L}\p{N}]+)/iu;
|
||||
|
@ -52,15 +52,17 @@ export type ParsedZap = {
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
function parseZapEvent(event: NostrEvent): ParsedZap {
|
||||
export function parseZapEvent(event: NostrEvent): ParsedZap {
|
||||
const zapRequestStr = event.tags.find(([t, v]) => t === "description")?.[1];
|
||||
if (!zapRequestStr) throw new Error("no description tag");
|
||||
|
||||
const bolt11 = event.tags.find((t) => t[0] === "bolt11")?.[1];
|
||||
if (!bolt11) throw new Error("missing bolt11 invoice");
|
||||
|
||||
const error = nip57.validateZapRequest(zapRequestStr);
|
||||
if (error) throw new Error(error);
|
||||
// TODO: disabled until signature verification can be offloaded to a web worker
|
||||
|
||||
// const error = nip57.validateZapRequest(zapRequestStr);
|
||||
// if (error) throw new Error(error);
|
||||
|
||||
const request = JSON.parse(zapRequestStr) as NostrEvent;
|
||||
const payment = parsePaymentRequest(bolt11);
|
||||
@ -75,15 +77,6 @@ function parseZapEvent(event: NostrEvent): ParsedZap {
|
||||
};
|
||||
}
|
||||
|
||||
const zapEventCache = new Map<string, ReturnType<typeof parseZapEvent>>();
|
||||
function cachedParseZapEvent(event: NostrEvent) {
|
||||
let result = zapEventCache.get(event.id);
|
||||
if (result) return result;
|
||||
result = parseZapEvent(event);
|
||||
if (result) zapEventCache.set(event.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function requestZapInvoice(zapRequest: NostrEvent, lnurl: string) {
|
||||
const amount = zapRequest.tags.find((t) => t[0] === "amount")?.[1];
|
||||
if (!amount) throw new Error("missing amount");
|
||||
@ -101,5 +94,3 @@ export async function requestZapInvoice(zapRequest: NostrEvent, lnurl: string) {
|
||||
return payRequest as string;
|
||||
} else throw new Error("Failed to get invoice");
|
||||
}
|
||||
|
||||
export { cachedParseZapEvent as parseZapEvent };
|
||||
|
@ -1,22 +1,29 @@
|
||||
import { useAsync } from "react-use";
|
||||
import extractMetaTags from "../lib/open-graph-scraper/extract";
|
||||
import { fetchWithCorsFallback } from "../helpers/cors";
|
||||
import { OgObjectInteral } from "../lib/open-graph-scraper/types";
|
||||
|
||||
const pageExtensions = [".html", ".php", "htm"];
|
||||
|
||||
const openGraphDataCache = new Map<string, OgObjectInteral>();
|
||||
|
||||
export default function useOpenGraphData(url: URL) {
|
||||
return useAsync(async () => {
|
||||
const controller = new AbortController();
|
||||
if (openGraphDataCache.has(url.toString())) return openGraphDataCache.get(url.toString());
|
||||
|
||||
const ext = url.pathname.match(/\.[\w+d]+$/)?.[0];
|
||||
if (ext && !pageExtensions.includes(ext)) return null;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const res = await fetchWithCorsFallback(url, { signal: controller.signal });
|
||||
const contentType = res.headers.get("content-type");
|
||||
|
||||
if (contentType?.includes("text/html")) {
|
||||
const html = await res.text();
|
||||
return extractMetaTags(html);
|
||||
const data = extractMetaTags(html);
|
||||
openGraphDataCache.set(url.toString(), data);
|
||||
return data;
|
||||
} else controller.abort();
|
||||
} catch (e) {}
|
||||
return null;
|
||||
|
@ -64,29 +64,29 @@ export default function IntersectionObserverProvider<T = undefined>({
|
||||
threshold,
|
||||
callback,
|
||||
}: PropsWithChildren & {
|
||||
root: MutableRefObject<HTMLElement | null>;
|
||||
root?: MutableRefObject<HTMLElement | null>;
|
||||
rootMargin?: IntersectionObserverInit["rootMargin"];
|
||||
threshold?: IntersectionObserverInit["threshold"];
|
||||
callback: ExtendedIntersectionObserverCallback<T>;
|
||||
}) {
|
||||
const elementIds = useMemo(() => new WeakMap<Element, T>(), []);
|
||||
const [observer, setObserver] = useState<IntersectionObserver>();
|
||||
|
||||
const handleIntersection = useCallback<IntersectionObserverCallback>((entries, observer) => {
|
||||
callback(
|
||||
entries.map((entry) => {
|
||||
return { entry, id: elementIds.get(entry.target) };
|
||||
}),
|
||||
observer
|
||||
);
|
||||
}, []);
|
||||
const [observer, setObserver] = useState<IntersectionObserver>(
|
||||
() => new IntersectionObserver(handleIntersection, { rootMargin, threshold })
|
||||
);
|
||||
|
||||
useMount(() => {
|
||||
if (root.current) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
callback(
|
||||
entries.map((entry) => {
|
||||
return { entry, id: elementIds.get(entry.target) };
|
||||
}),
|
||||
observer
|
||||
);
|
||||
},
|
||||
{ rootMargin, threshold }
|
||||
);
|
||||
|
||||
setObserver(observer);
|
||||
if (root?.current) {
|
||||
// recreate observer with root
|
||||
setObserver(new IntersectionObserver(handleIntersection, { rootMargin, threshold, root: root.current }));
|
||||
}
|
||||
});
|
||||
useUnmount(() => {
|
||||
|
@ -8,9 +8,9 @@ class SingleEventService {
|
||||
pendingPromises = new Map<string, Deferred<NostrEvent>>();
|
||||
|
||||
async requestEvent(id: string, relays: string[]) {
|
||||
if (this.eventCache.has(id)) {
|
||||
return this.eventCache.get(id);
|
||||
}
|
||||
const event = this.eventCache.get(id);
|
||||
if (event) return event;
|
||||
|
||||
this.pending.set(id, this.pending.get(id)?.concat(relays) ?? relays);
|
||||
const deferred = createDefer<NostrEvent>();
|
||||
this.pendingPromises.set(id, deferred);
|
||||
|
@ -63,11 +63,10 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||
setContent("");
|
||||
};
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider root={scrollBox} callback={callback}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex height="100%" overflow="hidden" direction="column">
|
||||
<Card size="sm" flexShrink={0}>
|
||||
<CardBody display="flex" gap="2" alignItems="center">
|
||||
|
@ -21,7 +21,7 @@ function useNotePointer() {
|
||||
}
|
||||
}
|
||||
|
||||
const NoteView = () => {
|
||||
export default function NoteView() {
|
||||
const pointer = useNotePointer();
|
||||
|
||||
const { thread, events, rootId, focusId, loading } = useThreadLoader(pointer.id, pointer.relays, {
|
||||
@ -51,7 +51,7 @@ const NoteView = () => {
|
||||
pageContent = (
|
||||
<>
|
||||
{parentPosts.map((parent) => (
|
||||
<Note key={parent.event.id + "-rely"} event={parent.event} maxHeight={200} />
|
||||
<Note key={parent.event.id + "-rely"} event={parent.event} />
|
||||
))}
|
||||
<ThreadPost key={post.event.id} post={post} initShowReplies focusId={focusId} />
|
||||
</>
|
||||
@ -65,6 +65,4 @@ const NoteView = () => {
|
||||
{pageContent}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteView;
|
||||
}
|
||||
|
@ -115,12 +115,11 @@ function NotificationsPage() {
|
||||
|
||||
const events = useSubject(timeline?.timeline) ?? [];
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback} root={scrollBox}>
|
||||
<Flex direction="column" overflowX="hidden" overflowY="auto" gap="2" ref={scrollBox}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex direction="column" gap="2">
|
||||
{events.map((event) => (
|
||||
<NotificationItem key={event.id} event={event} />
|
||||
))}
|
||||
|
@ -23,6 +23,7 @@ import { truncatedId } from "../../helpers/nostr-event";
|
||||
import QrScannerModal from "../../components/qr-scanner-modal";
|
||||
import { safeDecode } from "../../helpers/nip19";
|
||||
import { useInvoiceModalContext } from "../../providers/invoice-modal";
|
||||
import { matchHashtag } from "../../helpers/regexp";
|
||||
|
||||
type relay = string;
|
||||
type NostrBandSearchResults = {
|
||||
@ -91,13 +92,20 @@ export default function SearchView() {
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearchText = (text: string) => {
|
||||
if (text.startsWith("nostr:") || text.startsWith("web+nostr:") || safeDecode(search)) {
|
||||
const cleanText = text.trim();
|
||||
|
||||
if (cleanText.startsWith("nostr:") || cleanText.startsWith("web+nostr:") || safeDecode(search)) {
|
||||
navigate({ pathname: "/l/" + encodeURIComponent(text) }, { replace: true });
|
||||
} else if (text.trim().match(/^#(\w+)/i)) {
|
||||
navigate({ pathname: "/t/" + text.toLowerCase().trim().replace(/^#/, "") });
|
||||
} else {
|
||||
setSearchParams({ q: text }, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const hashTagMatch = cleanText.match(matchHashtag);
|
||||
if (hashTagMatch) {
|
||||
navigate({ pathname: "/t/" + hashTagMatch[2].toLocaleLowerCase() });
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams({ q: cleanText }, { replace: true });
|
||||
};
|
||||
|
||||
const readClipboard = useCallback(async () => {
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
renderImageUrl,
|
||||
} from "../../../components/embed-types";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import EmbeddedContent from "../../../components/embeded-content";
|
||||
|
||||
export default function StreamSummaryContent({ stream, ...props }: BoxProps & { stream: ParsedStream }) {
|
||||
const content = useMemo(() => {
|
||||
@ -32,7 +31,7 @@ export default function StreamSummaryContent({ stream, ...props }: BoxProps & {
|
||||
return (
|
||||
content && (
|
||||
<Box whiteSpace="pre-wrap" {...props}>
|
||||
<EmbeddedContent content={content} />
|
||||
{content}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
@ -31,7 +31,6 @@ function StreamsPage() {
|
||||
|
||||
useRelaysChanged(readRelays, () => timeline.reset());
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
const events = useSubject(timeline.timeline);
|
||||
@ -58,8 +57,8 @@ function StreamsPage() {
|
||||
</Select>
|
||||
<RelaySelectionButton ml="auto" />
|
||||
</Flex>
|
||||
<IntersectionObserverProvider callback={callback} root={scrollBox}>
|
||||
<Flex gap="2" wrap="wrap" overflowY="auto" overflowX="hidden" ref={scrollBox}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex gap="2" wrap="wrap">
|
||||
{streams.map((stream) => (
|
||||
<StreamCard key={stream.event.id} stream={stream} w="sm" />
|
||||
))}
|
||||
|
@ -8,7 +8,6 @@ import {
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
} from "../../../../components/embed-types";
|
||||
import EmbeddedContent from "../../../../components/embeded-content";
|
||||
import { NostrEvent } from "../../../../types/nostr-event";
|
||||
|
||||
const ChatMessageContent = React.memo(({ event }: { event: NostrEvent }) => {
|
||||
@ -26,7 +25,7 @@ const ChatMessageContent = React.memo(({ event }: { event: NostrEvent }) => {
|
||||
return c;
|
||||
}, [event.content]);
|
||||
|
||||
return <EmbeddedContent content={content} />;
|
||||
return <>{content}</>;
|
||||
});
|
||||
|
||||
export default ChatMessageContent;
|
||||
|
@ -18,14 +18,14 @@ function FollowerItem({ index, style, data: followers }: ListChildComponentProps
|
||||
);
|
||||
}
|
||||
|
||||
const UserFollowersTab = () => {
|
||||
export default function UserFollowersTab() {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
|
||||
const relays = useReadRelayUrls(useAdditionalRelayContext());
|
||||
const followers = useUserFollowers(pubkey, relays, true);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" overflowY="auto" p="2" h="full">
|
||||
<Flex gap="2" direction="column" p="2" h="90vh">
|
||||
{followers ? (
|
||||
<Box flex={1}>
|
||||
<AutoSizer disableWidth>
|
||||
@ -49,6 +49,4 @@ const UserFollowersTab = () => {
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFollowersTab;
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ export default function UserFollowingTab() {
|
||||
const contacts = useUserContacts(pubkey, contextRelays, true);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" overflowY="auto" p="2" h="full">
|
||||
<Flex gap="2" direction="column" p="2" h="90vh">
|
||||
{contacts ? (
|
||||
<Box flex={1}>
|
||||
<AutoSizer disableWidth>
|
||||
|
@ -109,7 +109,7 @@ const UserView = () => {
|
||||
flexGrow="1"
|
||||
isLazy
|
||||
index={activeTab}
|
||||
onChange={(v) => navigate(tabs[v].path)}
|
||||
onChange={(v) => navigate(tabs[v].path, { replace: true })}
|
||||
colorScheme="brand"
|
||||
>
|
||||
<TabList overflowX="auto" overflowY="hidden" flexShrink={0}>
|
||||
|
@ -43,7 +43,7 @@ const Like = ({ event }: { event: NostrEvent }) => {
|
||||
<Spacer />
|
||||
<NoteMenu event={event} aria-label="Note menu" variant="ghost" size="xs" />
|
||||
</Flex>
|
||||
<Note key={note.id} event={note} maxHeight={1200} />
|
||||
<Note key={note.id} event={note} />
|
||||
</>
|
||||
);
|
||||
} else content = <>Unknown note type {note.kind}</>;
|
||||
@ -60,13 +60,12 @@ export default function UserLikesTab() {
|
||||
|
||||
const lines = useSubject(timeline.timeline);
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback} root={scrollBox}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<TrustProvider trust>
|
||||
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto" ref={scrollBox}>
|
||||
<Flex direction="column" gap="2" p="2" pb="8">
|
||||
{lines.map((event) => (
|
||||
<Like event={event} />
|
||||
))}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useRef } from "react";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { truncatedId } from "../../helpers/nostr-event";
|
||||
@ -22,12 +21,11 @@ export default function UserStreamsTab() {
|
||||
{ "#p": [pubkey], kinds: [STREAM_KIND] },
|
||||
]);
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider<string> root={scrollBox} callback={callback}>
|
||||
<Flex direction="column" gap="2" pt="4" pb="8" h="full" overflowY="auto" overflowX="hidden" ref={scrollBox}>
|
||||
<IntersectionObserverProvider<string> callback={callback}>
|
||||
<Flex direction="column" gap="2" pt="4" pb="8">
|
||||
<GenericNoteTimeline timeline={timeline} />
|
||||
<TimelineActionAndStatus timeline={timeline} />
|
||||
</Flex>
|
||||
|
@ -102,12 +102,11 @@ const UserZapsTab = () => {
|
||||
return parsed;
|
||||
}, [events]);
|
||||
|
||||
const scrollBox = useRef<HTMLDivElement | null>(null);
|
||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback} root={scrollBox}>
|
||||
<Flex direction="column" gap="2" p="2" pb="8" h="full" overflowY="auto" ref={scrollBox}>
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<Flex direction="column" gap="2" p="2" pb="8">
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<Select value={filter} onChange={(e) => setFilter(e.target.value)} maxW="md">
|
||||
<option value="both">Note & Profile Zaps</option>
|
||||
|
Loading…
x
Reference in New Issue
Block a user