feat: media root tab

This commit is contained in:
2025-05-06 13:54:18 +01:00
parent 07474a836e
commit 91c912a886
14 changed files with 237 additions and 153 deletions

View File

@ -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";

View File

@ -94,6 +94,17 @@ export function rootTabItems(base: string, pubKey: string | undefined, tags: Arr
</>
),
},
{
tab: "media",
path: `${base}/media`,
show: true,
element: (
<>
<Icon name="camera-plus" />
<FormattedMessage defaultMessage="Media" />
</>
),
},
] as Array<{
tab: RootTabRoutePath;
path: string;

View File

@ -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<EventKind>;
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 (
<>
<DisplayAsSelector activeSelection={displayAs} onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)} />
{(props.showDisplayAsSelector ?? true) && (
<DisplayAsSelector activeSelection={displayAs} onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)} />
)}
{chunks.map(c => (
<TimelineChunk
key={c.until}
id="follows"
id={`follows${props.id ? `:${props.id}` : ""}`}
chunk={c}
builder={builder}
noteFilter={filterEvents}

View File

@ -59,7 +59,6 @@ export function MediaCol({ setThread }: { setThread: (e: NostrLink) => void }) {
</div>
<TimelineFollows
postsOnly={true}
liveStreams={false}
noteFilter={e => {
const parsed = transformTextCached(e.id, e.content, e.tags);
const images = parsed.filter(a => a.type === "media" && a.mimeType?.startsWith("image/"));

View File

@ -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 (
<div className="p">
<TimelineFollows
id="media"
postsOnly={true}
kinds={[EventKind.Photo, EventKind.Video, EventKind.ShortVideo]}
showDisplayAsSelector={true}
firstChunkSize={Day}
windowSize={Day}
/>
</div>
);
}

View File

@ -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 (
<>
<LiveStreams />
{(location.pathname === "/" || location.pathname === "/following") && <LiveStreams />}
<div className="main-content">
<Outlet />
</div>

View File

@ -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: <TopicsPage />,
},
{
path: "media",
element: <MediaPosts />,
},
];

View File

@ -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;

View File

@ -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<string>;
summary?: string;
alt?: string;
fallback?: Array<string>;
}
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<string>) {
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<Array<string>>) {
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<string> = ["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;
}

View File

@ -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/")),

View File

@ -0,0 +1,39 @@
import { Nip94Tags, readNip94Tags } from "./nip94";
/**
* Read NIP-94 tags from `imeta` tag
*/
export function readNip94TagsFromIMeta(tag: Array<string>) {
const asTags = tag.slice(1).map(a => a.split(" ", 2));
return readNip94Tags(asTags);
}
export function nip94TagsToIMeta(meta: Nip94Tags) {
const ret: Array<string> = ["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;
}

View File

@ -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<string>;
summary?: string;
alt?: string;
fallback?: Array<string>;
duration?: number;
bitrate?: number;
}
/**
* Read NIP-94 tags from event tags
*/
export function readNip94Tags(tags: Array<Array<string>>) {
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;
}

View File

@ -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";