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 { 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 ? (
|
||||
<LoggedInFollowButton tag={"t"} loggedIn={login.pubkey} value={tag} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
const login = useLogin();
|
||||
return login?.pubkey ? (
|
||||
<LoggedInFollowButton loggedIn={login.pubkey} pubkey={pubkey} />
|
||||
<LoggedInFollowButton tag={"p"} loggedIn={login.pubkey} value={pubkey} />
|
||||
) : null;
|
||||
}
|
||||
|
@ -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,13 +29,10 @@ export function Tags({
|
||||
{moment(Number(start) * 1000).fromNow()}
|
||||
</span>
|
||||
)}
|
||||
{ev.tags
|
||||
.filter((a) => a[0] === "t")
|
||||
.map((a) => a[1])
|
||||
.map((a) => (
|
||||
<span className="pill" key={a}>
|
||||
{tags.map((a) => (
|
||||
<a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}>
|
||||
{a}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -2,11 +2,11 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-tile.nsfw>div:nth-child(1) {
|
||||
.video-tile.nsfw > div:nth-child(1) {
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
.video-tile>div:nth-child(1) {
|
||||
.video-tile > div:nth-child(1) {
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
@ -43,3 +43,17 @@
|
||||
.video-tile .pill-box .pill.viewers {
|
||||
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 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 (
|
||||
<Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref}>
|
||||
<Link
|
||||
to={`/${link}`}
|
||||
className={`video-tile${contentWarning ? " nsfw" : ""}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<span className="pill-box">
|
||||
{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>
|
||||
<h3>{title}</h3>
|
||||
{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 { 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: <RootPage />,
|
||||
},
|
||||
{
|
||||
path: "/t/:tag",
|
||||
element: <TagPage />,
|
||||
},
|
||||
{
|
||||
path: "/p/:npub",
|
||||
element: <ProfilePage />,
|
||||
|
49
src/login.ts
49
src/login.ts
@ -72,11 +72,6 @@ export class LoginStore extends ExternalStore<LoginSession | undefined> {
|
||||
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<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 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<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, 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 (
|
||||
<div className="homepage">
|
||||
{hasFollowingLive && (
|
||||
<div className="video-grid">
|
||||
{live.map((e) => (
|
||||
{following.map((e) => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
{planned.length > 0 && (
|
||||
)}
|
||||
{!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>
|
||||
<div className="video-grid">
|
||||
{planned.map((e) => (
|
||||
{plannedEvents.map((e) => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
@ -76,7 +83,9 @@ export function RootPage() {
|
||||
<>
|
||||
<h2 className="divider line one-line">Ended</h2>
|
||||
<div className="video-grid">
|
||||
{ended.map((e) => (
|
||||
{ended
|
||||
.filter((e) => !mutedHosts.includes(getHost(e)))
|
||||
.map((e) => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</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) {
|
||||
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