From 75ff1dc37638a37c77aa548fd842d9263e6ceb92 Mon Sep 17 00:00:00 2001 From: verbiricha Date: Mon, 31 Jul 2023 12:19:51 +0200 Subject: [PATCH] homepage improvements - show followed streams first - show stream hashtags in video tile - allow to visit hashtag page by clicking tag - allow to follow/unfollow hashtags --- src/element/follow-button.tsx | 25 +++++-- src/element/tags.tsx | 22 +++--- src/element/video-tile.css | 70 ++++++++++-------- src/element/video-tile.tsx | 26 +++++-- src/hooks/live-streams.ts | 63 ++++++++++++++++ src/index.tsx | 5 ++ src/login.ts | 49 ------------- src/pages/root.tsx | 131 ++++++++++++++++++---------------- src/pages/tag.css | 12 ++++ src/pages/tag.tsx | 24 +++++++ src/utils.ts | 19 +++++ 11 files changed, 288 insertions(+), 158 deletions(-) create mode 100644 src/hooks/live-streams.ts create mode 100644 src/pages/tag.css create mode 100644 src/pages/tag.tsx diff --git a/src/element/follow-button.tsx b/src/element/follow-button.tsx index 42167f5..0e7750d 100644 --- a/src/element/follow-button.tsx +++ b/src/element/follow-button.tsx @@ -4,16 +4,22 @@ import { useLogin } from "hooks/login"; import AsyncButton from "element/async-button"; import { Login, System } from "index"; -export function LoggedInFollowButton({ pubkey }: { pubkey: string }) { +export function LoggedInFollowButton({ + tag, + value, +}: { + tag: "p" | "t"; + value: string; +}) { const login = useLogin(); const tags = login.follows.tags; - const follows = tags.filter((t) => t.at(0) === "p"); - const isFollowing = follows.find((t) => t.at(1) === pubkey); + const follows = tags.filter((t) => t.at(0) === tag); + const isFollowing = follows.find((t) => t.at(1) === value); async function unfollow() { const pub = login?.publisher(); if (pub) { - const newFollows = tags.filter((t) => t.at(1) !== pubkey); + const newFollows = tags.filter((t) => t.at(1) !== value); const ev = await pub.generic((eb) => { eb.kind(EventKind.ContactList).content(login.follows.content); for (const t of newFollows) { @@ -30,7 +36,7 @@ export function LoggedInFollowButton({ pubkey }: { pubkey: string }) { async function follow() { const pub = login?.publisher(); if (pub) { - const newFollows = [...tags, ["p", pubkey]]; + const newFollows = [...tags, [tag, value]]; const ev = await pub.generic((eb) => { eb.kind(EventKind.ContactList).content(login.follows.content); for (const tag of newFollows) { @@ -56,9 +62,16 @@ export function LoggedInFollowButton({ pubkey }: { pubkey: string }) { ); } +export function FollowTagButton({ tag }: { tag: string }) { + const login = useLogin(); + return login?.pubkey ? ( + + ) : null; +} + export function FollowButton({ pubkey }: { pubkey: string }) { const login = useLogin(); return login?.pubkey ? ( - + ) : null; } diff --git a/src/element/tags.tsx b/src/element/tags.tsx index fce9d8f..c4f446c 100644 --- a/src/element/tags.tsx +++ b/src/element/tags.tsx @@ -1,18 +1,25 @@ import type { ReactNode } from "react"; import moment from "moment"; + import { NostrEvent } from "@snort/system"; + import { StreamState } from "index"; -import { findTag } from "utils"; +import { findTag, getTagValues } from "utils"; export function Tags({ children, + max, ev, }: { children?: ReactNode; + max?: number; ev: NostrEvent; }) { const status = findTag(ev, "status"); const start = findTag(ev, "starts"); + const hashtags = getTagValues(ev.tags, "t"); + const tags = max ? hashtags.slice(0, max) : hashtags; + return ( <> {children} @@ -22,14 +29,11 @@ export function Tags({ {moment(Number(start) * 1000).fromNow()} )} - {ev.tags - .filter((a) => a[0] === "t") - .map((a) => a[1]) - .map((a) => ( - - {a} - - ))} + {tags.map((a) => ( + + {a} + + ))} ); } diff --git a/src/element/video-tile.css b/src/element/video-tile.css index 6c8674b..a47df95 100644 --- a/src/element/video-tile.css +++ b/src/element/video-tile.css @@ -1,45 +1,59 @@ .video-tile { - position: relative; + position: relative; } -.video-tile.nsfw>div:nth-child(1) { - filter: blur(3px); +.video-tile.nsfw > div:nth-child(1) { + filter: blur(3px); } -.video-tile>div:nth-child(1) { - border-radius: 16px; - width: 100%; - aspect-ratio: 16 / 10; - background-size: cover; - background-position: center; - background-repeat: no-repeat; +.video-tile > div:nth-child(1) { + border-radius: 16px; + width: 100%; + aspect-ratio: 16 / 10; + background-size: cover; + background-position: center; + background-repeat: no-repeat; } .video-tile h3 { - font-size: 20px; - line-height: 25px; - margin: 16px 0; - word-break: break-all; - word-wrap: break-word; + font-size: 20px; + line-height: 25px; + margin: 16px 0; + word-break: break-all; + word-wrap: break-word; } .video-tile .pill-box { - margin: 16px 20px; - text-transform: uppercase; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: flex-end; - position: absolute; - top: 0; - right: 0; - gap: 8px; + margin: 16px 20px; + text-transform: uppercase; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + position: absolute; + top: 0; + right: 0; + gap: 8px; } .video-tile .pill-box .pill { - width: fit-content; + width: fit-content; } .video-tile .pill-box .pill.viewers { - text-transform: lowercase; -} \ No newline at end of file + text-transform: lowercase; +} + +.video-tile .video-tags { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 4px; + position: absolute; + bottom: 4px; + left: 4px; +} + +.video-tile .video-tags .pill { + font-size: 12px; +} diff --git a/src/element/video-tile.tsx b/src/element/video-tile.tsx index b2c7184..6feaf1b 100644 --- a/src/element/video-tile.tsx +++ b/src/element/video-tile.tsx @@ -9,6 +9,7 @@ import { findTag, getHost } from "utils"; import { formatSats } from "number"; import ZapStream from "../../public/zap-stream.svg"; import { isContentWarningAccepted } from "./content-warning"; +import { Tags } from "element/tags"; export function VideoTile({ ev, @@ -25,7 +26,8 @@ export function VideoTile({ const image = findTag(ev, "image"); const status = findTag(ev, "status"); const viewers = findTag(ev, "current_participants"); - const contentWarning = findTag(ev, "content-warning") && !isContentWarningAccepted(); + const contentWarning = + findTag(ev, "content-warning") && !isContentWarningAccepted(); const host = getHost(ev); const link = encodeTLV( @@ -33,19 +35,33 @@ export function VideoTile({ id, undefined, ev.kind, - ev.pubkey + ev.pubkey, ); return ( - +
0 ? image : ZapStream) : ""})`, + position: "relative", + backgroundImage: `url(${ + inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : "" + })`, }} > +
+ +
{showStatus && } - {viewers && {formatSats(Number(viewers))} viewers} + {viewers && ( + + {formatSats(Number(viewers))} viewers + + )}

{title}

{showAuthor &&
{inView && }
} diff --git a/src/hooks/live-streams.ts b/src/hooks/live-streams.ts new file mode 100644 index 0000000..aee8303 --- /dev/null +++ b/src/hooks/live-streams.ts @@ -0,0 +1,63 @@ +import { useMemo } from "react"; + +import { NoteCollection, RequestBuilder } from "@snort/system"; +import { useRequestBuilder } from "@snort/system-react"; + +import { unixNow } from "@snort/shared"; +import { LIVE_STREAM } from "const"; +import { System, StreamState } from "index"; +import { findTag, dedupeByHost } from "utils"; + +export function useStreamsFeed(tag?: string) { + const rb = useMemo(() => { + const rb = new RequestBuilder(tag ? `streams:${tag}` : "streams"); + rb.withOptions({ + leaveOpen: true, + }); + if (tag) { + rb.withFilter() + .kinds([LIVE_STREAM]) + .tag("t", [tag]) + .since(unixNow() - 86400); + } else { + rb.withFilter() + .kinds([LIVE_STREAM]) + .since(unixNow() - 86400); + } + return rb; + }, [tag]); + + const feed = useRequestBuilder(System, NoteCollection, rb); + const feedSorted = useMemo(() => { + if (feed.data) { + return [...feed.data].sort((a, b) => { + const aStatus = findTag(a, "status")!; + const bStatus = findTag(b, "status")!; + if (aStatus === bStatus) { + const aStart = Number(findTag(a, "starts") ?? "0"); + const bStart = Number(findTag(b, "starts") ?? "0"); + return bStart > aStart ? 1 : -1; + } else { + return aStatus === "live" ? -1 : 1; + } + }); + } + return []; + }, [feed.data]); + + const live = dedupeByHost( + feedSorted.filter((a) => findTag(a, "status") === StreamState.Live), + ); + const planned = dedupeByHost( + feedSorted.filter((a) => findTag(a, "status") === StreamState.Planned), + ); + const ended = dedupeByHost( + feedSorted.filter((a) => { + const hasEnded = findTag(a, "status") === StreamState.Ended; + const recording = findTag(a, "recording"); + return hasEnded && recording?.length > 0; + }), + ); + + return { live, planned, ended }; +} diff --git a/src/index.tsx b/src/index.tsx index 09161db..bd5437e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import { NostrSystem } from "@snort/system"; import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { RootPage } from "pages/root"; +import { TagPage } from "pages/tag"; import { LayoutPage } from "pages/layout"; import { ProfilePage } from "pages/profile-page"; import { StreamPage } from "pages/stream-page"; @@ -41,6 +42,10 @@ const router = createBrowserRouter([ path: "/", element: , }, + { + path: "/t/:tag", + element: , + }, { path: "/p/:npub", element: , diff --git a/src/login.ts b/src/login.ts index 0b6e35e..1b20352 100644 --- a/src/login.ts +++ b/src/login.ts @@ -72,11 +72,6 @@ export class LoginStore extends ExternalStore { this.#save(); } - updateSession(s: LoginSession) { - this.#session = s; - this.#save(); - } - takeSnapshot() { return this.#session ? { ...this.#session } : undefined; } @@ -138,47 +133,3 @@ export function getPublisher(session: LoginSession) { } } } - -export function setFollows( - state: LoginSession, - follows: Array, - ts: number, -) { - if (state.follows.timestamp >= ts) { - return; - } - state.follows.tags = follows; - state.follows.timestamp = ts; -} - -export function setEmojis(state: LoginSession, emojis: Array) { - state.emojis = emojis; -} - -export function setMuted( - state: LoginSession, - muted: Array, - ts: number, -) { - if (state.muted.timestamp >= ts) { - return; - } - state.muted.tags = muted; - state.muted.timestamp = ts; -} - -export function setRelays( - state: LoginSession, - relays: Array, - ts: number, -) { - if (state.relays.timestamp >= ts) { - return; - } - state.relays = relays.reduce((acc, r) => { - const [, relay] = r; - const write = r.length === 2 || r.includes("write"); - const read = r.length === 2 || r.includes("read"); - return { ...acc, [relay]: { read, write } }; - }, {}); -} diff --git a/src/pages/root.tsx b/src/pages/root.tsx index 1d3bd34..260e8fb 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -1,72 +1,79 @@ import "./root.css"; +import type { NostrEvent } from "@snort/system"; -import { useMemo } from "react"; -import { unixNow } from "@snort/shared"; -import { - NoteCollection, - RequestBuilder, -} from "@snort/system"; -import { useRequestBuilder } from "@snort/system-react"; -import { StreamState, System } from ".."; -import { VideoTile } from "../element/video-tile"; -import { findTag } from "../utils"; -import { LIVE_STREAM } from "../const"; +import { VideoTile } from "element/video-tile"; +import { useLogin } from "hooks/login"; +import { getHost, getTagValues, dedupeByHost } from "utils"; +import { useStreamsFeed } from "hooks/live-streams"; export function RootPage() { - const rb = useMemo(() => { - const rb = new RequestBuilder("root"); - rb.withOptions({ - leaveOpen: true, - }) - .withFilter() - .kinds([LIVE_STREAM]) - .since(unixNow() - 86400); - return rb; - }, []); + const login = useLogin(); - const feed = useRequestBuilder( - System, - NoteCollection, - rb - ); - const feedSorted = useMemo(() => { - if (feed.data) { - return [...feed.data].sort((a, b) => { - const aStatus = findTag(a, "status")!; - const bStatus = findTag(b, "status")!; - if (aStatus === bStatus) { - const aStart = Number(findTag(a, "starts") ?? "0"); - const bStart = Number(findTag(b, "starts") ?? "0"); - return bStart > aStart ? 1 : -1; - } else { - return aStatus === "live" ? -1 : 1; - } - }); - } - return []; - }, [feed.data]); + const { live, planned, ended } = useStreamsFeed(); + const mutedHosts = getTagValues(login?.muted.tags ?? [], "p"); + const followsHost = (ev: NostrEvent) => { + return login?.follows.tags?.find((t) => t.at(1) === getHost(ev)); + }; + const hashtags = getTagValues(login?.follows.tags ?? [], "t"); + const following = dedupeByHost(live.filter(followsHost)); + const liveNow = dedupeByHost(live.filter((e) => !following.includes(e))); + const hasFollowingLive = following.length > 0; + + const plannedEvents = planned + .filter((e) => !mutedHosts.includes(getHost(e))) + .filter(followsHost); - const live = feedSorted.filter( - (a) => findTag(a, "status") === StreamState.Live - ); - const planned = feedSorted.filter( - (a) => findTag(a, "status") === StreamState.Planned - ); - const ended = feedSorted.filter( - (a) => findTag(a, "status") === StreamState.Ended - ); return (
-
- {live.map((e) => ( - - ))} -
- {planned.length > 0 && ( + {hasFollowingLive && ( +
+ {following.map((e) => ( + + ))} +
+ )} + {!hasFollowingLive && ( +
+ {live + .filter((e) => !mutedHosts.includes(getHost(e))) + .map((e) => ( + + ))} +
+ )} + {hashtags.map((t) => ( + <> +

#{t}

+
+ {live + .filter((e) => !mutedHosts.includes(getHost(e))) + .filter((e) => { + const evTags = getTagValues(e.tags, "t"); + return evTags.includes(t); + }) + .map((e) => ( + + ))} +
+ + ))} + {hasFollowingLive && liveNow.length > 0 && ( + <> +

Live

+
+ {liveNow + .filter((e) => !mutedHosts.includes(getHost(e))) + .map((e) => ( + + ))} +
+ + )} + {plannedEvents.length > 0 && ( <>

Planned

- {planned.map((e) => ( + {plannedEvents.map((e) => ( ))}
@@ -76,9 +83,11 @@ export function RootPage() { <>

Ended

- {ended.map((e) => ( - - ))} + {ended + .filter((e) => !mutedHosts.includes(getHost(e))) + .map((e) => ( + + ))}
)} diff --git a/src/pages/tag.css b/src/pages/tag.css new file mode 100644 index 0000000..b366551 --- /dev/null +++ b/src/pages/tag.css @@ -0,0 +1,12 @@ +@import "./root.css"; + +.tag-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 0 12px; +} + +.tag-page h1 { + margin: 0; +} diff --git a/src/pages/tag.tsx b/src/pages/tag.tsx new file mode 100644 index 0000000..f6809d1 --- /dev/null +++ b/src/pages/tag.tsx @@ -0,0 +1,24 @@ +import "./tag.css"; +import { useParams } from "react-router-dom"; + +import { VideoTile } from "element/video-tile"; +import { FollowTagButton } from "element/follow-button"; +import { useStreamsFeed } from "hooks/live-streams"; + +export function TagPage() { + const { tag } = useParams(); + const { live } = useStreamsFeed(tag); + return ( +
+
+

#{tag}

+ +
+
+ {live.map((e) => ( + + ))} +
+
+ ); +} diff --git a/src/utils.ts b/src/utils.ts index 6d915b5..6546fcc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -94,3 +94,22 @@ export async function openFile(): Promise { export function getTagValues(tags: Array, tag: string) { return tags.filter((t) => t.at(0) === tag).map((t) => t.at(1)); } + +export function dedupeByHost(events: Array) { + const { result } = events.reduce( + ({ result, seen }, ev) => { + const host = getHost(ev); + if (seen.has(host)) { + return { result, seen }; + } + result.push(ev); + seen.add(host); + return { result, seen }; + }, + { + result: [], + seen: new Set(), + }, + ); + return result; +}