diff --git a/src/element/emoji-pack.tsx b/src/element/emoji-pack.tsx
index 1e4cce2..532156e 100644
--- a/src/element/emoji-pack.tsx
+++ b/src/element/emoji-pack.tsx
@@ -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");
diff --git a/src/element/follow-button.tsx b/src/element/follow-button.tsx
index 42167f5..0e7750d 100644
--- a/src/element/follow-button.tsx
+++ b/src/element/follow-button.tsx
@@ -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 ? (
+
+ ) : null;
+}
+
export function FollowButton({ pubkey }: { pubkey: string }) {
const login = useLogin();
return login?.pubkey ? (
-
+
) : null;
}
diff --git a/src/element/tags.tsx b/src/element/tags.tsx
index fce9d8f..c4f446c 100644
--- a/src/element/tags.tsx
+++ b/src/element/tags.tsx
@@ -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()}
)}
- {ev.tags
- .filter((a) => a[0] === "t")
- .map((a) => a[1])
- .map((a) => (
-
- {a}
-
- ))}
+ {tags.map((a) => (
+
+ {a}
+
+ ))}
>
);
}
diff --git a/src/element/text.tsx b/src/element/text.tsx
index e419156..9b75421 100644
--- a/src/element/text.tsx
+++ b/src/element/text.tsx
@@ -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";
diff --git a/src/element/video-tile.css b/src/element/video-tile.css
index 6c8674b..6ac3108 100644
--- a/src/element/video-tile.css
+++ b/src/element/video-tile.css
@@ -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;
-}
\ No newline at end of file
+ text-transform: lowercase;
+}
+
+.video-tags {
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.video-tags .pill {
+ font-size: 12px;
+}
diff --git a/src/element/video-tile.tsx b/src/element/video-tile.tsx
index b2c7184..2f49bc3 100644
--- a/src/element/video-tile.tsx
+++ b/src/element/video-tile.tsx
@@ -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 (
-
-
0 ? image : ZapStream) : ""})`,
- }}
+
+
+
0 ? image : ZapStream) : ""
+ })`,
+ }}
+ >
+
+ {showStatus && }
+ {viewers && (
+
+ {formatSats(Number(viewers))} viewers
+
+ )}
+
+
{title}
+
+
-
- {showStatus && }
- {viewers && {formatSats(Number(viewers))} viewers}
-
-
{title}
- {showAuthor &&
}
-
+
);
}
diff --git a/src/hooks/live-chat.tsx b/src/hooks/live-chat.tsx
index f18a64c..8723648 100644
--- a/src/hooks/live-chat.tsx
+++ b/src/hooks/live-chat.tsx
@@ -11,9 +11,10 @@ import { useMemo } from "react";
import { LIVE_STREAM_CHAT } from "const";
export function useLiveChatFeed(link: NostrLink, eZaps?: Array
) {
- 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) {
const reactionsSub = useRequestBuilder(
System,
FlatNoteStore,
- esub
+ esub,
);
const reactions = reactionsSub.data ?? [];
diff --git a/src/hooks/live-streams.ts b/src/hooks/live-streams.ts
new file mode 100644
index 0000000..712cd06
--- /dev/null
+++ b/src/hooks/live-streams.ts
@@ -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(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 };
+}
diff --git a/src/index.tsx b/src/index.tsx
index 09161db..bd5437e 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -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: ,
},
+ {
+ path: "/t/:tag",
+ element: ,
+ },
{
path: "/p/:npub",
element: ,
diff --git a/src/pages/root.tsx b/src/pages/root.tsx
index 1d3bd34..84c0e69 100644
--- a/src/pages/root.tsx
+++ b/src/pages/root.tsx
@@ -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(
- 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 (
-
- {live.map((e) => (
-
- ))}
-
- {planned.length > 0 && (
+ {hasFollowingLive && (
+
+ {following.map((e) => (
+
+ ))}
+
+ )}
+ {!hasFollowingLive && (
+
+ {live
+ .filter((e) => !mutedHosts.has(getHost(e)))
+ .map((e) => (
+
+ ))}
+
+ )}
+ {hashtags.map((t) => (
+ <>
+
#{t}
+
+ {live
+ .filter((e) => !mutedHosts.has(getHost(e)))
+ .filter((e) => {
+ const evTags = getTagValues(e.tags, "t");
+ return evTags.includes(t);
+ })
+ .map((e) => (
+
+ ))}
+
+ >
+ ))}
+ {hasFollowingLive && liveNow.length > 0 && (
+ <>
+
Live
+
+ {liveNow
+ .filter((e) => !mutedHosts.has(getHost(e)))
+ .map((e) => (
+
+ ))}
+
+ >
+ )}
+ {plannedEvents.length > 0 && (
<>
Planned
- {planned.map((e) => (
+ {plannedEvents.map((e) => (
))}
>
)}
- {ended.length > 0 && (
+ {endedEvents.length > 0 && (
<>
Ended
- {ended.map((e) => (
+ {endedEvents.map((e) => (
))}
diff --git a/src/pages/tag.css b/src/pages/tag.css
new file mode 100644
index 0000000..b366551
--- /dev/null
+++ b/src/pages/tag.css
@@ -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;
+}
diff --git a/src/pages/tag.tsx b/src/pages/tag.tsx
new file mode 100644
index 0000000..f6809d1
--- /dev/null
+++ b/src/pages/tag.tsx
@@ -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 (
+
+
+
#{tag}
+
+
+
+ {live.map((e) => (
+
+ ))}
+
+
+ );
+}