forked from Kieran/zap.stream
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
This commit is contained in:
parent
e726a67413
commit
75ff1dc376
@ -4,16 +4,22 @@ import { useLogin } from "hooks/login";
|
|||||||
import AsyncButton from "element/async-button";
|
import AsyncButton from "element/async-button";
|
||||||
import { Login, System } from "index";
|
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 login = useLogin();
|
||||||
const tags = login.follows.tags;
|
const tags = login.follows.tags;
|
||||||
const follows = tags.filter((t) => t.at(0) === "p");
|
const follows = tags.filter((t) => t.at(0) === tag);
|
||||||
const isFollowing = follows.find((t) => t.at(1) === pubkey);
|
const isFollowing = follows.find((t) => t.at(1) === value);
|
||||||
|
|
||||||
async function unfollow() {
|
async function unfollow() {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
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) => {
|
const ev = await pub.generic((eb) => {
|
||||||
eb.kind(EventKind.ContactList).content(login.follows.content);
|
eb.kind(EventKind.ContactList).content(login.follows.content);
|
||||||
for (const t of newFollows) {
|
for (const t of newFollows) {
|
||||||
@ -30,7 +36,7 @@ export function LoggedInFollowButton({ pubkey }: { pubkey: string }) {
|
|||||||
async function follow() {
|
async function follow() {
|
||||||
const pub = login?.publisher();
|
const pub = login?.publisher();
|
||||||
if (pub) {
|
if (pub) {
|
||||||
const newFollows = [...tags, ["p", pubkey]];
|
const newFollows = [...tags, [tag, value]];
|
||||||
const ev = await pub.generic((eb) => {
|
const ev = await pub.generic((eb) => {
|
||||||
eb.kind(EventKind.ContactList).content(login.follows.content);
|
eb.kind(EventKind.ContactList).content(login.follows.content);
|
||||||
for (const tag of newFollows) {
|
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 ? (
|
||||||
|
<LoggedInFollowButton tag={"t"} loggedIn={login.pubkey} value={tag} />
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function FollowButton({ pubkey }: { pubkey: string }) {
|
export function FollowButton({ pubkey }: { pubkey: string }) {
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
return login?.pubkey ? (
|
return login?.pubkey ? (
|
||||||
<LoggedInFollowButton loggedIn={login.pubkey} pubkey={pubkey} />
|
<LoggedInFollowButton tag={"p"} loggedIn={login.pubkey} value={pubkey} />
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,25 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
import { NostrEvent } from "@snort/system";
|
import { NostrEvent } from "@snort/system";
|
||||||
|
|
||||||
import { StreamState } from "index";
|
import { StreamState } from "index";
|
||||||
import { findTag } from "utils";
|
import { findTag, getTagValues } from "utils";
|
||||||
|
|
||||||
export function Tags({
|
export function Tags({
|
||||||
children,
|
children,
|
||||||
|
max,
|
||||||
ev,
|
ev,
|
||||||
}: {
|
}: {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
max?: number;
|
||||||
ev: NostrEvent;
|
ev: NostrEvent;
|
||||||
}) {
|
}) {
|
||||||
const status = findTag(ev, "status");
|
const status = findTag(ev, "status");
|
||||||
const start = findTag(ev, "starts");
|
const start = findTag(ev, "starts");
|
||||||
|
const hashtags = getTagValues(ev.tags, "t");
|
||||||
|
const tags = max ? hashtags.slice(0, max) : hashtags;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@ -22,14 +29,11 @@ export function Tags({
|
|||||||
{moment(Number(start) * 1000).fromNow()}
|
{moment(Number(start) * 1000).fromNow()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{ev.tags
|
{tags.map((a) => (
|
||||||
.filter((a) => a[0] === "t")
|
<a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}>
|
||||||
.map((a) => a[1])
|
{a}
|
||||||
.map((a) => (
|
</a>
|
||||||
<span className="pill" key={a}>
|
))}
|
||||||
{a}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,45 +1,59 @@
|
|||||||
.video-tile {
|
.video-tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-tile.nsfw>div:nth-child(1) {
|
.video-tile.nsfw > div:nth-child(1) {
|
||||||
filter: blur(3px);
|
filter: blur(3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-tile>div:nth-child(1) {
|
.video-tile > div:nth-child(1) {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-tile h3 {
|
.video-tile h3 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 25px;
|
line-height: 25px;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-tile .pill-box {
|
.video-tile .pill-box {
|
||||||
margin: 16px 20px;
|
margin: 16px 20px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-tile .pill-box .pill {
|
.video-tile .pill-box .pill {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-tile .pill-box .pill.viewers {
|
.video-tile .pill-box .pill.viewers {
|
||||||
text-transform: lowercase;
|
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;
|
||||||
|
}
|
||||||
|
@ -9,6 +9,7 @@ import { findTag, getHost } from "utils";
|
|||||||
import { formatSats } from "number";
|
import { formatSats } from "number";
|
||||||
import ZapStream from "../../public/zap-stream.svg";
|
import ZapStream from "../../public/zap-stream.svg";
|
||||||
import { isContentWarningAccepted } from "./content-warning";
|
import { isContentWarningAccepted } from "./content-warning";
|
||||||
|
import { Tags } from "element/tags";
|
||||||
|
|
||||||
export function VideoTile({
|
export function VideoTile({
|
||||||
ev,
|
ev,
|
||||||
@ -25,7 +26,8 @@ export function VideoTile({
|
|||||||
const image = findTag(ev, "image");
|
const image = findTag(ev, "image");
|
||||||
const status = findTag(ev, "status");
|
const status = findTag(ev, "status");
|
||||||
const viewers = findTag(ev, "current_participants");
|
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 host = getHost(ev);
|
||||||
|
|
||||||
const link = encodeTLV(
|
const link = encodeTLV(
|
||||||
@ -33,19 +35,33 @@ export function VideoTile({
|
|||||||
id,
|
id,
|
||||||
undefined,
|
undefined,
|
||||||
ev.kind,
|
ev.kind,
|
||||||
ev.pubkey
|
ev.pubkey,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref}>
|
<Link
|
||||||
|
to={`/${link}`}
|
||||||
|
className={`video-tile${contentWarning ? " nsfw" : ""}`}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""})`,
|
position: "relative",
|
||||||
|
backgroundImage: `url(${
|
||||||
|
inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""
|
||||||
|
})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="video-tags">
|
||||||
|
<Tags ev={ev} max={3} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="pill-box">
|
<span className="pill-box">
|
||||||
{showStatus && <StatePill state={status as StreamState} />}
|
{showStatus && <StatePill state={status as StreamState} />}
|
||||||
{viewers && <span className="pill viewers">{formatSats(Number(viewers))} viewers</span>}
|
{viewers && (
|
||||||
|
<span className="pill viewers">
|
||||||
|
{formatSats(Number(viewers))} viewers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
|
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
|
||||||
|
63
src/hooks/live-streams.ts
Normal file
63
src/hooks/live-streams.ts
Normal file
@ -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<NoteCollection>(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 };
|
||||||
|
}
|
@ -7,6 +7,7 @@ import { NostrSystem } from "@snort/system";
|
|||||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
import { RootPage } from "pages/root";
|
import { RootPage } from "pages/root";
|
||||||
|
import { TagPage } from "pages/tag";
|
||||||
import { LayoutPage } from "pages/layout";
|
import { LayoutPage } from "pages/layout";
|
||||||
import { ProfilePage } from "pages/profile-page";
|
import { ProfilePage } from "pages/profile-page";
|
||||||
import { StreamPage } from "pages/stream-page";
|
import { StreamPage } from "pages/stream-page";
|
||||||
@ -41,6 +42,10 @@ const router = createBrowserRouter([
|
|||||||
path: "/",
|
path: "/",
|
||||||
element: <RootPage />,
|
element: <RootPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/t/:tag",
|
||||||
|
element: <TagPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/p/:npub",
|
path: "/p/:npub",
|
||||||
element: <ProfilePage />,
|
element: <ProfilePage />,
|
||||||
|
49
src/login.ts
49
src/login.ts
@ -72,11 +72,6 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
|||||||
this.#save();
|
this.#save();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSession(s: LoginSession) {
|
|
||||||
this.#session = s;
|
|
||||||
this.#save();
|
|
||||||
}
|
|
||||||
|
|
||||||
takeSnapshot() {
|
takeSnapshot() {
|
||||||
return this.#session ? { ...this.#session } : undefined;
|
return this.#session ? { ...this.#session } : undefined;
|
||||||
}
|
}
|
||||||
@ -138,47 +133,3 @@ export function getPublisher(session: LoginSession) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setFollows(
|
|
||||||
state: LoginSession,
|
|
||||||
follows: Array<string>,
|
|
||||||
ts: number,
|
|
||||||
) {
|
|
||||||
if (state.follows.timestamp >= ts) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.follows.tags = follows;
|
|
||||||
state.follows.timestamp = ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setEmojis(state: LoginSession, emojis: Array<EmojiPack>) {
|
|
||||||
state.emojis = emojis;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setMuted(
|
|
||||||
state: LoginSession,
|
|
||||||
muted: Array<string[]>,
|
|
||||||
ts: number,
|
|
||||||
) {
|
|
||||||
if (state.muted.timestamp >= ts) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.muted.tags = muted;
|
|
||||||
state.muted.timestamp = ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setRelays(
|
|
||||||
state: LoginSession,
|
|
||||||
relays: Array<string>,
|
|
||||||
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 } };
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
@ -1,72 +1,79 @@
|
|||||||
import "./root.css";
|
import "./root.css";
|
||||||
|
import type { NostrEvent } from "@snort/system";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { VideoTile } from "element/video-tile";
|
||||||
import { unixNow } from "@snort/shared";
|
import { useLogin } from "hooks/login";
|
||||||
import {
|
import { getHost, getTagValues, dedupeByHost } from "utils";
|
||||||
NoteCollection,
|
import { useStreamsFeed } from "hooks/live-streams";
|
||||||
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";
|
|
||||||
|
|
||||||
export function RootPage() {
|
export function RootPage() {
|
||||||
const rb = useMemo(() => {
|
const login = useLogin();
|
||||||
const rb = new RequestBuilder("root");
|
|
||||||
rb.withOptions({
|
|
||||||
leaveOpen: true,
|
|
||||||
})
|
|
||||||
.withFilter()
|
|
||||||
.kinds([LIVE_STREAM])
|
|
||||||
.since(unixNow() - 86400);
|
|
||||||
return rb;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const feed = useRequestBuilder<NoteCollection>(
|
const { live, planned, ended } = useStreamsFeed();
|
||||||
System,
|
const mutedHosts = getTagValues(login?.muted.tags ?? [], "p");
|
||||||
NoteCollection,
|
const followsHost = (ev: NostrEvent) => {
|
||||||
rb
|
return login?.follows.tags?.find((t) => t.at(1) === getHost(ev));
|
||||||
);
|
};
|
||||||
const feedSorted = useMemo(() => {
|
const hashtags = getTagValues(login?.follows.tags ?? [], "t");
|
||||||
if (feed.data) {
|
const following = dedupeByHost(live.filter(followsHost));
|
||||||
return [...feed.data].sort((a, b) => {
|
const liveNow = dedupeByHost(live.filter((e) => !following.includes(e)));
|
||||||
const aStatus = findTag(a, "status")!;
|
const hasFollowingLive = following.length > 0;
|
||||||
const bStatus = findTag(b, "status")!;
|
|
||||||
if (aStatus === bStatus) {
|
const plannedEvents = planned
|
||||||
const aStart = Number(findTag(a, "starts") ?? "0");
|
.filter((e) => !mutedHosts.includes(getHost(e)))
|
||||||
const bStart = Number(findTag(b, "starts") ?? "0");
|
.filter(followsHost);
|
||||||
return bStart > aStart ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
return aStatus === "live" ? -1 : 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [feed.data]);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="homepage">
|
<div className="homepage">
|
||||||
<div className="video-grid">
|
{hasFollowingLive && (
|
||||||
{live.map((e) => (
|
<div className="video-grid">
|
||||||
<VideoTile ev={e} key={e.id} />
|
{following.map((e) => (
|
||||||
))}
|
<VideoTile ev={e} key={e.id} />
|
||||||
</div>
|
))}
|
||||||
{planned.length > 0 && (
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasFollowingLive && (
|
||||||
|
<div className="video-grid">
|
||||||
|
{live
|
||||||
|
.filter((e) => !mutedHosts.includes(getHost(e)))
|
||||||
|
.map((e) => (
|
||||||
|
<VideoTile ev={e} key={e.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hashtags.map((t) => (
|
||||||
|
<>
|
||||||
|
<h2 className="divider line one-line">#{t}</h2>
|
||||||
|
<div className="video-grid">
|
||||||
|
{live
|
||||||
|
.filter((e) => !mutedHosts.includes(getHost(e)))
|
||||||
|
.filter((e) => {
|
||||||
|
const evTags = getTagValues(e.tags, "t");
|
||||||
|
return evTags.includes(t);
|
||||||
|
})
|
||||||
|
.map((e) => (
|
||||||
|
<VideoTile ev={e} key={e.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{hasFollowingLive && liveNow.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className="divider line one-line">Live</h2>
|
||||||
|
<div className="video-grid">
|
||||||
|
{liveNow
|
||||||
|
.filter((e) => !mutedHosts.includes(getHost(e)))
|
||||||
|
.map((e) => (
|
||||||
|
<VideoTile ev={e} key={e.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{plannedEvents.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h2 className="divider line one-line">Planned</h2>
|
<h2 className="divider line one-line">Planned</h2>
|
||||||
<div className="video-grid">
|
<div className="video-grid">
|
||||||
{planned.map((e) => (
|
{plannedEvents.map((e) => (
|
||||||
<VideoTile ev={e} key={e.id} />
|
<VideoTile ev={e} key={e.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -76,9 +83,11 @@ export function RootPage() {
|
|||||||
<>
|
<>
|
||||||
<h2 className="divider line one-line">Ended</h2>
|
<h2 className="divider line one-line">Ended</h2>
|
||||||
<div className="video-grid">
|
<div className="video-grid">
|
||||||
{ended.map((e) => (
|
{ended
|
||||||
<VideoTile ev={e} key={e.id} />
|
.filter((e) => !mutedHosts.includes(getHost(e)))
|
||||||
))}
|
.map((e) => (
|
||||||
|
<VideoTile ev={e} key={e.id} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
12
src/pages/tag.css
Normal file
12
src/pages/tag.css
Normal file
@ -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;
|
||||||
|
}
|
24
src/pages/tag.tsx
Normal file
24
src/pages/tag.tsx
Normal file
@ -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 (
|
||||||
|
<div className="tag-page">
|
||||||
|
<div className="tag-page-header">
|
||||||
|
<h1>#{tag}</h1>
|
||||||
|
<FollowTagButton tag={tag} />
|
||||||
|
</div>
|
||||||
|
<div className="video-grid">
|
||||||
|
{live.map((e) => (
|
||||||
|
<VideoTile ev={e} key={e.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
19
src/utils.ts
19
src/utils.ts
@ -94,3 +94,22 @@ export async function openFile(): Promise<File | undefined> {
|
|||||||
export function getTagValues(tags: Array<string[]>, tag: string) {
|
export function getTagValues(tags: Array<string[]>, tag: string) {
|
||||||
return tags.filter((t) => t.at(0) === tag).map((t) => t.at(1));
|
return tags.filter((t) => t.at(0) === tag).map((t) => t.at(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dedupeByHost(events: Array<NostrEvent>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user