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"