Merge pull request 'home improvements' (#54) from home into main

Reviewed-on: Kieran/stream#54
This commit is contained in:
Kieran 2023-08-01 09:16:07 +00:00
commit 56783bfc8d
12 changed files with 290 additions and 122 deletions

View File

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

View File

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

View File

@ -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>
))}
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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
View 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>
);
}