forked from Kieran/zap.stream
Merge pull request 'home improvements' (#54) from home into main
Reviewed-on: Kieran/stream#54
This commit is contained in:
commit
56783bfc8d
@ -12,7 +12,7 @@ import { Login, System } from "index";
|
||||
export function EmojiPack({ ev }: { ev: NostrEvent }) {
|
||||
const login = useLogin();
|
||||
const name = findTag(ev, "d");
|
||||
const isUsed = login.emojis.find(
|
||||
const isUsed = login?.emojis.find(
|
||||
(e) => e.author === ev.pubkey && e.name === name,
|
||||
);
|
||||
const emoji = ev.tags.filter((e) => e.at(0) === "emoji");
|
||||
|
@ -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,14 +29,11 @@ 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}>
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
{tags.map((a) => (
|
||||
<a href={`/t/${encodeURIComponent(a)}`} className="pill" key={a}>
|
||||
{a}
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { useMemo, type ReactNode } from "react";
|
||||
|
||||
import { parseNostrLink, validateNostrLink } from "@snort/system";
|
||||
|
||||
import { Address } from "element/Address";
|
||||
import { Address } from "element/address";
|
||||
import { Event } from "element/Event";
|
||||
import { Mention } from "element/mention";
|
||||
import { Emoji } from "element/emoji";
|
||||
|
@ -1,45 +1,67 @@
|
||||
.video-tile {
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-tile.nsfw>div:nth-child(1) {
|
||||
filter: blur(3px);
|
||||
.video-tile-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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 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 6px 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;
|
||||
}
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.video-tags {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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,22 +35,38 @@ export function VideoTile({
|
||||
id,
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
ev.pubkey,
|
||||
);
|
||||
return (
|
||||
<Link to={`/${link}`} className={`video-tile${contentWarning ? " nsfw" : ""}`} ref={ref}>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""})`,
|
||||
}}
|
||||
<div className="video-tile-container">
|
||||
<Link
|
||||
to={`/${link}`}
|
||||
className={`video-tile${contentWarning ? " nsfw" : ""}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${
|
||||
inView ? ((image?.length ?? 0) > 0 ? image : ZapStream) : ""
|
||||
})`,
|
||||
}}
|
||||
></div>
|
||||
<span className="pill-box">
|
||||
{showStatus && <StatePill state={status as StreamState} />}
|
||||
{viewers && (
|
||||
<span className="pill viewers">
|
||||
{formatSats(Number(viewers))} viewers
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<h3>{title}</h3>
|
||||
</Link>
|
||||
<div className="video-tile-info">
|
||||
<div className="video-tags">
|
||||
<Tags ev={ev} max={3} />
|
||||
</div>
|
||||
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
|
||||
</div>
|
||||
<span className="pill-box">
|
||||
{showStatus && <StatePill state={status as StreamState} />}
|
||||
{viewers && <span className="pill viewers">{formatSats(Number(viewers))} viewers</span>}
|
||||
</span>
|
||||
<h3>{title}</h3>
|
||||
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -11,9 +11,10 @@ import { useMemo } from "react";
|
||||
import { LIVE_STREAM_CHAT } from "const";
|
||||
|
||||
export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
const since = useMemo(() =>
|
||||
unixNow() - (60 * 60 * 24 * 7), // 7-days of zaps
|
||||
[link.id]);
|
||||
const since = useMemo(
|
||||
() => unixNow() - 60 * 60 * 24 * 7, // 7-days of zaps
|
||||
[link.id],
|
||||
);
|
||||
const sub = useMemo(() => {
|
||||
const rb = new RequestBuilder(`live:${link.id}:${link.author}`);
|
||||
rb.withOptions({
|
||||
@ -57,7 +58,7 @@ export function useLiveChatFeed(link: NostrLink, eZaps?: Array<string>) {
|
||||
const reactionsSub = useRequestBuilder<FlatNoteStore>(
|
||||
System,
|
||||
FlatNoteStore,
|
||||
esub
|
||||
esub,
|
||||
);
|
||||
|
||||
const reactions = reactionsSub.data ?? [];
|
||||
|
57
src/hooks/live-streams.ts
Normal file
57
src/hooks/live-streams.ts
Normal file
@ -0,0 +1,57 @@
|
||||
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 } from "utils";
|
||||
|
||||
export function useStreamsFeed(tag?: string) {
|
||||
const since = useMemo(() => unixNow() - 86400, [tag]);
|
||||
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(since);
|
||||
} else {
|
||||
rb.withFilter().kinds([LIVE_STREAM]).since(since);
|
||||
}
|
||||
return rb;
|
||||
}, [tag, since]);
|
||||
|
||||
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 = feedSorted.filter(
|
||||
(a) => findTag(a, "status") === StreamState.Live,
|
||||
);
|
||||
const planned = feedSorted.filter(
|
||||
(a) => findTag(a, "status") === StreamState.Planned,
|
||||
);
|
||||
const ended = 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 />,
|
||||
|
@ -1,82 +1,94 @@
|
||||
import "./root.css";
|
||||
import { useCallback } from "react";
|
||||
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 } 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 { live, planned, ended } = useStreamsFeed();
|
||||
const mutedHosts = new Set(getTagValues(login?.muted.tags ?? [], "p"));
|
||||
const followsHost = useCallback(
|
||||
(ev: NostrEvent) => {
|
||||
return login?.follows.tags.find((t) => t.at(1) === getHost(ev));
|
||||
},
|
||||
[login?.follows],
|
||||
);
|
||||
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 hashtags = getTagValues(login?.follows.tags ?? [], "t");
|
||||
const following = live.filter(followsHost);
|
||||
const liveNow = live.filter((e) => !following.includes(e));
|
||||
const hasFollowingLive = following.length > 0;
|
||||
|
||||
const plannedEvents = planned
|
||||
.filter((e) => !mutedHosts.has(getHost(e)))
|
||||
.filter(followsHost);
|
||||
const endedEvents = ended.filter((e) => !mutedHosts.has(getHost(e)));
|
||||
|
||||
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">
|
||||
<div className="video-grid">
|
||||
{live.map((e) => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
{planned.length > 0 && (
|
||||
{hasFollowingLive && (
|
||||
<div className="video-grid">
|
||||
{following.map((e) => (
|
||||
<VideoTile ev={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasFollowingLive && (
|
||||
<div className="video-grid">
|
||||
{live
|
||||
.filter((e) => !mutedHosts.has(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.has(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.has(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>
|
||||
</>
|
||||
)}
|
||||
{ended.length > 0 && (
|
||||
{endedEvents.length > 0 && (
|
||||
<>
|
||||
<h2 className="divider line one-line">Ended</h2>
|
||||
<div className="video-grid">
|
||||
{ended.map((e) => (
|
||||
{endedEvents.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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user