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:
verbiricha 2023-07-31 12:19:51 +02:00
parent e726a67413
commit 75ff1dc376
11 changed files with 288 additions and 158 deletions

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

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

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

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

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

View File

@ -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">
<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.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,9 +83,11 @@ export function RootPage() {
<>
<h2 className="divider line one-line">Ended</h2>
<div className="video-grid">
{ended.map((e) => (
<VideoTile ev={e} key={e.id} />
))}
{ended
.filter((e) => !mutedHosts.includes(getHost(e)))
.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>
);
}

View File

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