diff --git a/package.json b/package.json index d86a7a8fb..dfb2b2f00 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "bech32": "^2.0.0", + "blurhash": "^2.0.5", "cheerio": "^1.0.0-rc.12", "dayjs": "^1.11.9", "debug": "^4.3.4", diff --git a/src/app.tsx b/src/app.tsx index ae21fd0b1..d0ee73d56 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -42,6 +42,7 @@ const StreamsView = React.lazy(() => import("./views/streams")); const StreamView = React.lazy(() => import("./views/streams/stream")); const SearchView = React.lazy(() => import("./views/search")); const MapView = React.lazy(() => import("./views/map")); +const FilesView = React.lazy(() => import("./views/files")); const RootPage = () => { useSetColorMode(); @@ -106,6 +107,7 @@ const router = createHashRouter([ element: , }, { path: "settings", element: }, + { path: "files", element: }, { path: "relays/reviews", element: }, { path: "relays", element: }, { path: "r/:relay", element: }, diff --git a/src/components/blured-image.tsx b/src/components/blured-image.tsx new file mode 100644 index 000000000..e76677c0c --- /dev/null +++ b/src/components/blured-image.tsx @@ -0,0 +1,23 @@ +import { Box, Image, ImageProps, useDisclosure } from "@chakra-ui/react"; + +export default function BlurredImage(props: ImageProps) { + const { isOpen, onOpen } = useDisclosure(); + return ( + + { + e.stopPropagation(); + e.preventDefault(); + onOpen(); + } + : undefined + } + cursor="pointer" + filter={isOpen ? "" : "blur(1.5rem)"} + {...props} + /> + + ); +} diff --git a/src/components/blurhash-image.tsx b/src/components/blurhash-image.tsx new file mode 100644 index 000000000..2ca970c2e --- /dev/null +++ b/src/components/blurhash-image.tsx @@ -0,0 +1,29 @@ +import { Box, BoxProps } from "@chakra-ui/react"; +import { decode } from "blurhash"; +import { useEffect, useRef } from "react"; + +export type BlurhashImageProps = { + blurhash: string; + width: number; + height: number; +} & Omit; + +export default function BlurhashImage({ blurhash, width, height, ...props }: BlurhashImageProps) { + const canvasRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return; + const ctx = canvasRef.current.getContext("2d"); + if (!ctx) return; + + ctx.canvas.width = width; + ctx.canvas.height = height; + + const imageData = ctx.createImageData(width, height); + const pixels = decode(blurhash, width, height); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + }, [blurhash, width, height]); + + return ; +} diff --git a/src/components/embed-types/common.tsx b/src/components/embed-types/common.tsx index 32804bc27..53e82ca87 100644 --- a/src/components/embed-types/common.tsx +++ b/src/components/embed-types/common.tsx @@ -3,28 +3,7 @@ import appSettings from "../../services/settings/app-settings"; import { ImageGalleryLink } from "../image-gallery"; import { useTrusted } from "../../providers/trust"; import OpenGraphCard from "../open-graph-card"; - -const BlurredImage = (props: ImageProps) => { - const { isOpen, onOpen } = useDisclosure(); - return ( - - { - e.stopPropagation(); - e.preventDefault(); - onOpen(); - } - : undefined - } - cursor="pointer" - filter={isOpen ? "" : "blur(1.5rem)"} - {...props} - /> - - ); -}; +import BlurredImage from "../blured-image"; const EmbeddedImage = ({ src }: { src: string }) => { const trusted = useTrusted(); diff --git a/src/components/icons.tsx b/src/components/icons.tsx index c51820633..a9b1e1d7d 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -301,3 +301,9 @@ export const StarHalfIcon = createIcon({ d: "M12.0006 15.968L16.2473 18.3451L15.2988 13.5717L18.8719 10.2674L14.039 9.69434L12.0006 5.27502V15.968ZM12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z", defaultProps, }); + +export const FileIcon = createIcon({ + displayName: "FileIcon", + d: "M9 2.00318V2H19.9978C20.5513 2 21 2.45531 21 2.9918V21.0082C21 21.556 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5501 3 20.9932V8L9 2.00318ZM5.82918 8H9V4.83086L5.82918 8ZM11 4V9C11 9.55228 10.5523 10 10 10H5V20H19V4H11Z", + defaultProps, +}); diff --git a/src/components/layout/desktop-side-nav.tsx b/src/components/layout/desktop-side-nav.tsx index dc93d3f7a..e140e921b 100644 --- a/src/components/layout/desktop-side-nav.tsx +++ b/src/components/layout/desktop-side-nav.tsx @@ -8,6 +8,7 @@ import { ChatIcon, EditIcon, FeedIcon, + FileIcon, LiveStreamIcon, LogoutIcon, MapIcon, @@ -50,6 +51,9 @@ export default function DesktopSideNav(props: Omit) { + diff --git a/src/components/layout/mobile-side-drawer.tsx b/src/components/layout/mobile-side-drawer.tsx index 8e642e6a0..c647f3276 100644 --- a/src/components/layout/mobile-side-drawer.tsx +++ b/src/components/layout/mobile-side-drawer.tsx @@ -13,7 +13,17 @@ import { } from "@chakra-ui/react"; import { Link as RouterLink, useNavigate } from "react-router-dom"; import { ConnectedRelays } from "../connected-relays"; -import { HomeIcon, LiveStreamIcon, LogoutIcon, ProfileIcon, RelayIcon, SearchIcon, SettingsIcon } from "../icons"; +import { + FileIcon, + HomeIcon, + LiveStreamIcon, + LogoutIcon, + MapIcon, + ProfileIcon, + RelayIcon, + SearchIcon, + SettingsIcon, +} from "../icons"; import { UserAvatar } from "../user-avatar"; import { UserLink } from "../user-link"; import AccountSwitcher from "./account-switcher"; @@ -57,6 +67,12 @@ export default function MobileSideDrawer({ ...props }: Omit navigate("/streams")} leftIcon={}> Streams + + diff --git a/src/helpers/nostr/files.ts b/src/helpers/nostr/files.ts new file mode 100644 index 000000000..3d02b79be --- /dev/null +++ b/src/helpers/nostr/files.ts @@ -0,0 +1,44 @@ +import { NostrEvent } from "../../types/nostr-event"; + +export type ParsedImageFile = { + url: string; + mimeType: string; + width?: number; + height?: number; + size?: number; + magnet?: string; + sha256Hash?: string; + infoHash?: string; + blurhash?: string; +}; + +export function parseImageFile(event: NostrEvent): ParsedImageFile { + const url = event.tags.find((t) => t[0] === "url" && t[1])?.[1]; + const mimeType = event.tags.find((t) => t[0] === "m" && t[1])?.[1]; + const magnet = event.tags.find((t) => t[0] === "magnet" && t[1])?.[1]; + const infoHash = event.tags.find((t) => t[0] === "i" && t[1])?.[1]; + const size = event.tags.find((t) => t[0] === "i" && t[1])?.[1]; + const sha256Hash = event.tags.find((t) => t[0] === "x" && t[1])?.[1]; + const blurhash = event.tags.find((t) => t[0] === "blurhash" && t[1])?.[1]; + + const dimensions = event.tags.find((t) => t[0] === "dim" && t[1])?.[1]; + const [width, height] = dimensions?.split("x").map((v) => parseInt(v)) ?? []; + + if (!url) throw new Error("missing url"); + if (!mimeType) throw new Error("missing MIME Type"); + if (width !== undefined && height !== undefined) { + if (!Number.isFinite(width) || !Number.isFinite(height)) throw new Error("bad dimensions"); + } + + return { + url, + mimeType, + width, + height, + magnet, + infoHash, + sha256Hash, + blurhash, + size: size ? parseInt(size) : undefined, + }; +} diff --git a/src/types/nostr-query.ts b/src/types/nostr-query.ts index dd08a8b94..ed86d5629 100644 --- a/src/types/nostr-query.ts +++ b/src/types/nostr-query.ts @@ -18,6 +18,7 @@ export type NostrQuery = { "#r"?: string[]; "#l"?: string[]; "#g"?: string[]; + "#m"?: string[]; since?: number; until?: number; limit?: number; diff --git a/src/views/files/index.tsx b/src/views/files/index.tsx new file mode 100644 index 000000000..12bc6fc0a --- /dev/null +++ b/src/views/files/index.tsx @@ -0,0 +1,160 @@ +import { + Button, + Checkbox, + Code, + Divider, + Flex, + Image, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, + SimpleGrid, + Text, + useDisclosure, +} from "@chakra-ui/react"; +import { useCallback, useState } from "react"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import useSubject from "../../hooks/use-subject"; +import { NostrEvent } from "../../types/nostr-event"; +import { parseImageFile } from "../../helpers/nostr/files"; +import BlurhashImage from "../../components/blurhash-image"; +import { ErrorBoundary } from "../../components/error-boundary"; +import useAppSettings from "../../hooks/use-app-settings"; +import { useTrusted } from "../../providers/trust"; +import BlurredImage from "../../components/blured-image"; + +const FILE_KIND = 1063; +const VIDEO_TYPES = ["video/mp4", "video/webm"]; +const IMAGE_TYPES = ["image/png", "image/jpeg", "image/svg+xml", "image/webp", "image/gif"]; +const AUDIO_TYPES = ["audio/webm", "audio/wav", "audio/ogg"]; +const TEXT_TYPES = ["text/plain"]; + +function ImageFile({ event }: { event: NostrEvent }) { + const parsed = parseImageFile(event); + const settings = useAppSettings(); + const trust = useTrusted(); + + const shouldBlur = settings.blurImages && !trust; + + const showImage = useDisclosure(); + if (shouldBlur && parsed.blurhash && parsed.width && parsed.height && !showImage.isOpen) { + const aspect = parsed.width / parsed.height; + return ( + + ); + } + + const ImageComponent = shouldBlur ? BlurredImage : Image; + return ; +} + +function FileType({ event }: { event: NostrEvent }) { + const mimeType = event.tags.find((t) => t[0] === "m" && t[1])?.[1]; + + if (!mimeType) throw new Error("missing MIME type"); + + if (IMAGE_TYPES.includes(mimeType)) { + return ; + } + return Unknown mine type {mimeType}; +} + +export default function FilesView() { + const [selectedTypes, setSelectedTypes] = useState(IMAGE_TYPES); + const toggleType = useCallback( + (type: string) => { + setSelectedTypes((arr) => { + if (arr.includes(type)) { + return arr.filter((t) => t !== type); + } else return arr.concat(type); + }); + }, + [setSelectedTypes] + ); + + const relays = useReadRelayUrls(); + const timeline = useTimelineLoader( + "files", + relays, + { kinds: [FILE_KIND], "#m": selectedTypes }, + { enabled: selectedTypes.length > 0 } + ); + + const events = useSubject(timeline.timeline); + + return ( + + + + + + + + + + + + + Images + + {IMAGE_TYPES.map((type) => ( + toggleType(type)}> + {type} + + ))} + + + Videos + + {VIDEO_TYPES.map((type) => ( + toggleType(type)}> + {type} + + ))} + + + Audio + + {AUDIO_TYPES.map((type) => ( + toggleType(type)}> + {type} + + ))} + + + Text + + {TEXT_TYPES.map((type) => ( + toggleType(type)}> + {type} + + ))} + + + + + + + + + {events.map((event) => ( + + + {/* {JSON.stringify(event, null, 2)} */} + + ))} + + + ); +} diff --git a/yarn.lock b/yarn.lock index 518eb51d2..371aca2fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2964,6 +2964,11 @@ bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +blurhash@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.5.tgz#efde729fc14a2f03571a6aa91b49cba80d1abe4b" + integrity sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w== + boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"