From 91c912a8869dac2f5319f10ffd92af681922ff2c Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 6 May 2025 13:54:18 +0100 Subject: [PATCH] feat: media root tab --- .../Components/Event/Create/NoteCreator.tsx | 14 +- .../app/src/Components/Feed/RootTabItems.tsx | 11 ++ .../src/Components/Feed/TimelineFollows.tsx | 19 ++- packages/app/src/Pages/Deck/Columns.tsx | 1 - packages/app/src/Pages/Root/Media.tsx | 19 +++ packages/app/src/Pages/Root/Root.css | 0 packages/app/src/Pages/Root/RootRoutes.tsx | 5 +- packages/app/src/Pages/Root/RootTabRoutes.tsx | 8 +- packages/app/src/Utils/Upload/Nip96.ts | 4 +- packages/app/src/Utils/Upload/index.ts | 141 +----------------- packages/app/src/Utils/getEventMedia.ts | 15 +- packages/system/src/impl/nip92.ts | 39 +++++ packages/system/src/impl/nip94.ts | 112 ++++++++++++++ packages/system/src/index.ts | 2 + 14 files changed, 237 insertions(+), 153 deletions(-) create mode 100644 packages/app/src/Pages/Root/Media.tsx delete mode 100644 packages/app/src/Pages/Root/Root.css create mode 100644 packages/system/src/impl/nip92.ts create mode 100644 packages/system/src/impl/nip94.ts diff --git a/packages/app/src/Components/Event/Create/NoteCreator.tsx b/packages/app/src/Components/Event/Create/NoteCreator.tsx index cfe76b5a..ee0902b1 100644 --- a/packages/app/src/Components/Event/Create/NoteCreator.tsx +++ b/packages/app/src/Components/Event/Create/NoteCreator.tsx @@ -1,6 +1,16 @@ /* eslint-disable max-lines */ import { fetchNip05Pubkey, unixNow } from "@snort/shared"; -import { EventBuilder, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system"; +import { + addExtensionToNip94Url, + EventBuilder, + EventKind, + nip94TagsToIMeta, + NostrLink, + NostrPrefix, + readNip94Tags, + TaggedNostrEvent, + tryParseNostrLink, +} from "@snort/system"; import { useUserProfile } from "@snort/system-react"; import { ZapTarget } from "@snort/wallet"; import { Menu, MenuItem } from "@szhsin/react-menu"; @@ -28,7 +38,7 @@ import usePreferences from "@/Hooks/usePreferences"; import useRelays from "@/Hooks/useRelays"; import { useNoteCreator } from "@/State/NoteCreator"; import { openFile, trackEvent } from "@/Utils"; -import useFileUpload, { addExtensionToNip94Url, nip94TagsToIMeta, readNip94Tags } from "@/Utils/Upload"; +import useFileUpload from "@/Utils/Upload"; import { GetPowWorker } from "@/Utils/wasm"; import { OkResponseRow } from "./OkResponseRow"; diff --git a/packages/app/src/Components/Feed/RootTabItems.tsx b/packages/app/src/Components/Feed/RootTabItems.tsx index 252406c7..e955132a 100644 --- a/packages/app/src/Components/Feed/RootTabItems.tsx +++ b/packages/app/src/Components/Feed/RootTabItems.tsx @@ -94,6 +94,17 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr ), }, + { + tab: "media", + path: `${base}/media`, + show: true, + element: ( + <> + + + + ), + }, ] as Array<{ tab: RootTabRoutePath; path: string; diff --git a/packages/app/src/Components/Feed/TimelineFollows.tsx b/packages/app/src/Components/Feed/TimelineFollows.tsx index ac8fc0a9..d4fa0485 100644 --- a/packages/app/src/Components/Feed/TimelineFollows.tsx +++ b/packages/app/src/Components/Feed/TimelineFollows.tsx @@ -15,12 +15,16 @@ import { AutoLoadMore } from "../Event/LoadMore"; import TimelineChunk from "./TimelineChunk"; export interface TimelineFollowsProps { + id?: string; postsOnly: boolean; - liveStreams?: boolean; noteFilter?: (ev: NostrEvent) => boolean; noteRenderer?: (ev: NostrEvent) => ReactNode; noteOnClick?: (ev: NostrEvent) => void; displayAs?: DisplayAs; + kinds?: Array; + showDisplayAsSelector?: boolean; + firstChunkSize?: number; + windowSize?: number; } /** @@ -38,12 +42,15 @@ const TimelineFollows = (props: TimelineFollowsProps) => { const { isFollowing, followList } = useFollowsControls(); const { chunks, showMore } = useTimelineChunks({ now: openedAt, - firstChunkSize: Hour * 2, + window: props.windowSize, + firstChunkSize: props.firstChunkSize ?? Hour * 2, }); const builder = useCallback( (rb: RequestBuilder) => { - rb.withFilter().authors(followList).kinds([EventKind.TextNote, EventKind.Repost, EventKind.Polls]); + rb.withFilter() + .authors(followList) + .kinds(props.kinds ?? [EventKind.TextNote, EventKind.Repost, EventKind.Polls]); }, [followList], ); @@ -58,11 +65,13 @@ const TimelineFollows = (props: TimelineFollowsProps) => { return ( <> - setDisplayAs(displayAs)} /> + {(props.showDisplayAsSelector ?? true) && ( + setDisplayAs(displayAs)} /> + )} {chunks.map(c => ( void }) { { const parsed = transformTextCached(e.id, e.content, e.tags); const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/")); diff --git a/packages/app/src/Pages/Root/Media.tsx b/packages/app/src/Pages/Root/Media.tsx new file mode 100644 index 00000000..6a9a8eeb --- /dev/null +++ b/packages/app/src/Pages/Root/Media.tsx @@ -0,0 +1,19 @@ +import { EventKind } from "@snort/system"; + +import TimelineFollows from "@/Components/Feed/TimelineFollows"; +import { Day } from "@/Utils/Const"; + +export default function MediaPosts() { + return ( +
+ +
+ ); +} diff --git a/packages/app/src/Pages/Root/Root.css b/packages/app/src/Pages/Root/Root.css deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/app/src/Pages/Root/RootRoutes.tsx b/packages/app/src/Pages/Root/RootRoutes.tsx index 6e28c792..8e0d3404 100644 --- a/packages/app/src/Pages/Root/RootRoutes.tsx +++ b/packages/app/src/Pages/Root/RootRoutes.tsx @@ -1,5 +1,5 @@ import { lazy } from "react"; -import { Outlet, RouteObject } from "react-router-dom"; +import { Outlet, RouteObject, useLocation } from "react-router-dom"; import { LiveStreams } from "@/Components/LiveStream/LiveStreams"; import { RootTabRoutes } from "@/Pages/Root/RootTabRoutes"; @@ -8,9 +8,10 @@ import { getCurrentRefCode } from "@/Utils"; const InviteModal = lazy(() => import("@/Components/Invite")); export default function RootPage() { const code = getCurrentRefCode(); + const location = useLocation(); return ( <> - + {(location.pathname === "/" || location.pathname === "/following") && }
diff --git a/packages/app/src/Pages/Root/RootTabRoutes.tsx b/packages/app/src/Pages/Root/RootTabRoutes.tsx index c61bc97c..0f8c63bd 100644 --- a/packages/app/src/Pages/Root/RootTabRoutes.tsx +++ b/packages/app/src/Pages/Root/RootTabRoutes.tsx @@ -7,6 +7,7 @@ import { ConversationsTab } from "@/Pages/Root/ConversationsTab"; import { DefaultTab } from "@/Pages/Root/DefaultTab"; import { FollowedByFriendsTab } from "@/Pages/Root/FollowedByFriendsTab"; import { ForYouTab } from "@/Pages/Root/ForYouTab"; +import MediaPosts from "@/Pages/Root/Media"; import { NotesTab } from "@/Pages/Root/NotesTab"; import { TagsTab } from "@/Pages/Root/TagsTab"; import { TopicsPage } from "@/Pages/TopicsPage"; @@ -23,7 +24,8 @@ export type RootTabRoutePath = | "trending/hashtags" | "suggested" | "t/:tag" - | "topics"; + | "topics" + | "media"; export type RootTabRoute = { path: RootTabRoutePath; @@ -83,4 +85,8 @@ export const RootTabRoutes: RootTabRoute[] = [ path: "topics", element: , }, + { + path: "media", + element: , + }, ]; diff --git a/packages/app/src/Utils/Upload/Nip96.ts b/packages/app/src/Utils/Upload/Nip96.ts index 724c10e3..6a32e2ed 100644 --- a/packages/app/src/Utils/Upload/Nip96.ts +++ b/packages/app/src/Utils/Upload/Nip96.ts @@ -1,8 +1,8 @@ import { base64 } from "@scure/base"; import { throwIfOffline } from "@snort/shared"; -import { EventKind, EventPublisher, NostrEvent } from "@snort/system"; +import { addExtensionToNip94Url, EventKind, EventPublisher, NostrEvent, readNip94Tags } from "@snort/system"; -import { addExtensionToNip94Url, readNip94Tags, UploadResult } from "."; +import { UploadResult } from "."; export class Nip96Uploader { #info?: Nip96Info; diff --git a/packages/app/src/Utils/Upload/index.ts b/packages/app/src/Utils/Upload/index.ts index 9e7bd3a3..46500598 100644 --- a/packages/app/src/Utils/Upload/index.ts +++ b/packages/app/src/Utils/Upload/index.ts @@ -1,28 +1,12 @@ -import { EventPublisher, NostrEvent } from "@snort/system"; +import { EventPublisher, Nip94Tags, NostrEvent } from "@snort/system"; import useEventPublisher from "@/Hooks/useEventPublisher"; import { useMediaServerList } from "@/Hooks/useMediaServerList"; import { bech32ToHex, randomSample } from "@/Utils"; -import { FileExtensionRegex, KieranPubKey } from "@/Utils/Const"; +import { KieranPubKey } from "@/Utils/Const"; import { Nip96Uploader } from "./Nip96"; -export interface Nip94Tags { - url?: string; - mimeType?: string; - hash?: string; - originalHash?: string; - size?: number; - dimensions?: [number, number]; - magnet?: string; - blurHash?: string; - thumb?: string; - image?: Array; - summary?: string; - alt?: string; - fallback?: Array; -} - export interface UploadResult { url?: string; error?: string; @@ -86,124 +70,3 @@ export default function useFileUpload(privKey?: string) { return new Nip96Uploader("https://nostr.build", pub); } } - -export function addExtensionToNip94Url(meta: Nip94Tags) { - if (!meta.url?.match(FileExtensionRegex) && meta.mimeType) { - switch (meta.mimeType) { - case "image/webp": { - return `${meta.url}.webp`; - } - case "image/jpeg": - case "image/jpg": { - return `${meta.url}.jpg`; - } - case "video/mp4": { - return `${meta.url}.mp4`; - } - } - } - return meta.url; -} - -/** - * Read NIP-94 tags from `imeta` tag - */ -export function readNip94TagsFromIMeta(tag: Array) { - const asTags = tag.slice(1).map(a => a.split(" ", 2)); - return readNip94Tags(asTags); -} - -/** - * Read NIP-94 tags from event tags - */ -export function readNip94Tags(tags: Array>) { - const res: Nip94Tags = {}; - for (const tx of tags) { - const [k, v] = tx; - switch (k) { - case "url": { - res.url = v; - break; - } - case "m": { - res.mimeType = v; - break; - } - case "x": { - res.hash = v; - break; - } - case "ox": { - res.originalHash = v; - break; - } - case "size": { - res.size = Number(v); - break; - } - case "dim": { - res.dimensions = v.split("x").map(Number) as [number, number]; - break; - } - case "magnet": { - res.magnet = v; - break; - } - case "blurhash": { - res.blurHash = v; - break; - } - case "thumb": { - res.thumb = v; - break; - } - case "image": { - res.image ??= []; - res.image.push(v); - break; - } - case "summary": { - res.summary = v; - break; - } - case "alt": { - res.alt = v; - break; - } - case "fallback": { - res.fallback ??= []; - res.fallback.push(v); - break; - } - } - } - return res; -} - -export function nip94TagsToIMeta(meta: Nip94Tags) { - const ret: Array = ["imeta"]; - const ifPush = (key: string, value?: string | number) => { - if (value) { - ret.push(`${key} ${value}`); - } - }; - ifPush("url", meta.url); - ifPush("m", meta.mimeType); - ifPush("x", meta.hash); - ifPush("ox", meta.originalHash); - ifPush("size", meta.size); - ifPush("dim", meta.dimensions?.join("x")); - ifPush("magnet", meta.magnet); - ifPush("blurhash", meta.blurHash); - ifPush("thumb", meta.thumb); - ifPush("summary", meta.summary); - ifPush("alt", meta.alt); - if (meta.image) { - meta.image.forEach(a => ifPush("image", a)); - } - if (meta.fallback) { - meta.fallback.forEach(a => ifPush("fallback", a)); - } - - return ret; -} diff --git a/packages/app/src/Utils/getEventMedia.ts b/packages/app/src/Utils/getEventMedia.ts index a2fa0bca..90a568dc 100644 --- a/packages/app/src/Utils/getEventMedia.ts +++ b/packages/app/src/Utils/getEventMedia.ts @@ -1,8 +1,21 @@ -import { TaggedNostrEvent } from "@snort/system"; +import { EventKind, ParsedFragment, readNip94TagsFromIMeta, TaggedNostrEvent } from "@snort/system"; import { transformTextCached } from "@/Hooks/useTextTransformCache"; export default function getEventMedia(event: TaggedNostrEvent) { + // emulate parsed media from imeta kinds + const mediaKinds = [EventKind.Photo, EventKind.Video, EventKind.ShortVideo]; + if (mediaKinds.includes(event.kind)) { + const meta = event.tags.filter(a => a[0] === "imeta").map(readNip94TagsFromIMeta); + return meta.map( + a => + ({ + type: "media", + mimeType: a.mimeType, + content: a.url, + }) as ParsedFragment, + ); + } const parsed = transformTextCached(event.id, event.content, event.tags); return parsed.filter( a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")), diff --git a/packages/system/src/impl/nip92.ts b/packages/system/src/impl/nip92.ts new file mode 100644 index 00000000..bc61f95d --- /dev/null +++ b/packages/system/src/impl/nip92.ts @@ -0,0 +1,39 @@ +import { Nip94Tags, readNip94Tags } from "./nip94"; + +/** + * Read NIP-94 tags from `imeta` tag + */ +export function readNip94TagsFromIMeta(tag: Array) { + const asTags = tag.slice(1).map(a => a.split(" ", 2)); + return readNip94Tags(asTags); +} + +export function nip94TagsToIMeta(meta: Nip94Tags) { + const ret: Array = ["imeta"]; + const ifPush = (key: string, value?: string | number) => { + if (value) { + ret.push(`${key} ${value}`); + } + }; + ifPush("url", meta.url); + ifPush("m", meta.mimeType); + ifPush("x", meta.hash); + ifPush("ox", meta.originalHash); + ifPush("size", meta.size); + ifPush("dim", meta.dimensions?.join("x")); + ifPush("magnet", meta.magnet); + ifPush("blurhash", meta.blurHash); + ifPush("thumb", meta.thumb); + ifPush("summary", meta.summary); + ifPush("alt", meta.alt); + ifPush("duration", meta.duration); + ifPush("bitrate", meta.bitrate); + if (meta.image) { + meta.image.forEach(a => ifPush("image", a)); + } + if (meta.fallback) { + meta.fallback.forEach(a => ifPush("fallback", a)); + } + + return ret; +} diff --git a/packages/system/src/impl/nip94.ts b/packages/system/src/impl/nip94.ts new file mode 100644 index 00000000..2a0c4d07 --- /dev/null +++ b/packages/system/src/impl/nip94.ts @@ -0,0 +1,112 @@ +import { FileExtensionRegex } from "../const"; + +export interface Nip94Tags { + url?: string; + mimeType?: string; + hash?: string; + originalHash?: string; + size?: number; + dimensions?: [number, number]; + magnet?: string; + blurHash?: string; + thumb?: string; + image?: Array; + summary?: string; + alt?: string; + fallback?: Array; + duration?: number; + bitrate?: number; +} + +/** + * Read NIP-94 tags from event tags + */ +export function readNip94Tags(tags: Array>) { + const res: Nip94Tags = {}; + for (const tx of tags) { + const [k, v] = tx; + switch (k) { + case "url": { + res.url = v; + break; + } + case "m": { + res.mimeType = v; + break; + } + case "x": { + res.hash = v; + break; + } + case "ox": { + res.originalHash = v; + break; + } + case "size": { + res.size = Number(v); + break; + } + case "dim": { + res.dimensions = v.split("x").map(Number) as [number, number]; + break; + } + case "magnet": { + res.magnet = v; + break; + } + case "blurhash": { + res.blurHash = v; + break; + } + case "thumb": { + res.thumb = v; + break; + } + case "image": { + res.image ??= []; + res.image.push(v); + break; + } + case "summary": { + res.summary = v; + break; + } + case "alt": { + res.alt = v; + break; + } + case "fallback": { + res.fallback ??= []; + res.fallback.push(v); + break; + } + case "duration": { + res.duration = Number(v); + break; + } + case "bitrate": { + res.bitrate = Number(v); + break; + } + } + } + return res; +} + +export function addExtensionToNip94Url(meta: Nip94Tags) { + if (!meta.url?.match(FileExtensionRegex) && meta.mimeType) { + switch (meta.mimeType) { + case "image/webp": { + return `${meta.url}.webp`; + } + case "image/jpeg": + case "image/jpg": { + return `${meta.url}.jpg`; + } + case "video/mp4": { + return `${meta.url}.mp4`; + } + } + } + return meta.url; +} diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 13394f20..e7f5a6c7 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -35,6 +35,8 @@ export * from "./impl/nip44"; export * from "./impl/nip46"; export * from "./impl/nip57"; export * from "./impl/nip55"; +export * from "./impl/nip94"; +export * from "./impl/nip92"; export * from "./cache/index"; export * from "./cache/user-relays";