feat: profile page

This commit is contained in:
Alejandro Gomez 2023-06-30 13:15:31 +02:00
parent 9a08dd6768
commit 111eea3d14
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
24 changed files with 745 additions and 152 deletions

View File

@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-tabs": "^1.0.4",
"@react-hook/resize-observer": "^1.2.6",
"@snort/system-react": "^1.0.8",
"@testing-library/jest-dom": "^5.14.1",

4
src/const.ts Normal file
View File

@ -0,0 +1,4 @@
import { EventKind } from "@snort/system";
export const LIVE_STREAM = 30_311 as EventKind;
export const LIVE_STREAM_CHAT = 1_311 as EventKind;

View File

@ -0,0 +1,4 @@
// todo
export function FollowButton({ pubkey }: { pubkey: string }) {
return <button className="btn btn-primary">Follow</button>;
}

View File

@ -187,10 +187,6 @@
margin: 0;
}
.zap-icon {
color: #FFCB44;
}
.zap-container {
position: relative;
border-radius: 12px;
@ -211,11 +207,11 @@
}
.zap-container .profile {
color: #FFCB44;
color: #FF8D2B;
}
.zap-container .zap-amount {
color: #FFCB44;
color: #FF8D2B;
}
.zap-content {

View File

@ -9,7 +9,6 @@ import {
} from "@snort/system";
import {
useState,
useMemo,
useEffect,
type KeyboardEvent,
type ChangeEvent,
@ -27,41 +26,27 @@ import Spinner from "./spinner";
import { useLogin } from "hooks/login";
import { useUserProfile } from "@snort/system-react";
import { formatSats } from "number";
import useTopZappers from "hooks/top-zappers";
export interface LiveChatOptions {
canWrite?: boolean;
showHeader?: boolean;
}
function totalZapped(pubkey: string, zaps: ParsedZap[]) {
return zaps
.filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey))
.reduce((acc, z) => acc + z.amount, 0);
}
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = zaps
.map((z) => (z.anonZap ? "anon" : z.sender))
.map((p) => p as string);
const sortedZappers = useMemo(() => {
const sorted = [...new Set([...zappers])];
sorted.sort((a, b) => totalZapped(b, zaps) - totalZapped(a, zaps));
return sorted.slice(0, 3);
}, [zaps, zappers]);
const zappers = useTopZappers(zaps).slice(0, 3);
return (
<>
<h3>Top zappers</h3>
<div className="top-zappers-container">
{sortedZappers.map((pk, idx) => {
const total = totalZapped(pk, zaps);
{zappers.map(({ pubkey, total }, idx) => {
return (
<div className="top-zapper" key={pk}>
{pk === "anon" ? (
<div className="top-zapper" key={pubkey}>
{pubkey === "anon" ? (
<p className="top-zapper-name">Anon</p>
) : (
<Profile pubkey={pk} options={{ showName: false }} />
<Profile pubkey={pubkey} options={{ showName: false }} />
)}
<Icon name="zap-filled" className="zap-icon" />
<p className="top-zapper-amount">{formatSats(total)}</p>
@ -132,7 +117,7 @@ function ChatMessage({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) {
return (
<div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}>
<Profile pubkey={ev.pubkey} />
<Text ev={ev} />
<Text content={ev.content} tags={ev.tags} />
</div>
);
}

View File

@ -1,5 +1,7 @@
import { Link } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { System } from "index";
import { hexToBech32 } from "utils";
interface MentionProps {
pubkey: string;
@ -8,13 +10,6 @@ interface MentionProps {
export function Mention({ pubkey, relays }: MentionProps) {
const user = useUserProfile(System, pubkey);
return (
<a
href={`https://snort.social/p/${pubkey}`}
target="_blank"
rel="noreferrer"
>
{user?.name || pubkey}
</a>
);
const npub = hexToBech32("npub", pubkey);
return <Link to={`/p/${npub}`}>{user?.name || pubkey}</Link>;
}

View File

@ -1,7 +1,9 @@
import "./profile.css";
import { Link } from "react-router-dom";
import { useUserProfile } from "@snort/system-react";
import { UserMetadata } from "@snort/system";
import { hexToBech32 } from "@snort/shared";
import { Icon } from "element/icon";
import { System } from "index";
export interface ProfileOptions {
@ -12,13 +14,14 @@ export interface ProfileOptions {
}
export function getName(pk: string, user?: UserMetadata) {
const shortPubkey = hexToBech32("npub", pk).slice(0, 12);
if ((user?.display_name?.length ?? 0) > 0) {
return user?.display_name;
}
const npub = hexToBech32("npub", pk);
const shortPubkey = npub.slice(0, 12);
if ((user?.name?.length ?? 0) > 0) {
return user?.name;
}
if ((user?.display_name?.length ?? 0) > 0) {
return user?.display_name;
}
return shortPubkey;
}
@ -33,17 +36,32 @@ export function Profile({
}) {
const profile = useUserProfile(System, pubkey);
return (
<div className="profile">
{(options?.showAvatar ?? true) && (
const content = (
<>
{(options?.showAvatar ?? true) && pubkey === "anon" ? (
<Icon size={40} name="zap-filled" />
) : (
<img
alt={profile?.name || pubkey}
className={avatarClassname ? avatarClassname : ""}
src={profile?.picture ?? ""}
/>
)}
{(options?.showName ?? true) &&
(options?.overrideName ?? getName(pubkey, profile))}
</div>
{(options?.showName ?? true) && (
<span>
{options?.overrideName ?? pubkey === "anon"
? "Anon"
: getName(pubkey, profile)}
</span>
)}
</>
);
return pubkey === "anon" ? (
<div className="profile">{content}</div>
) : (
<Link to={`/p/${hexToBech32("npub", pubkey)}`} className="profile">
{content}
</Link>
);
}

View File

@ -1,6 +1,6 @@
import "./send-zap.css";
import * as Dialog from "@radix-ui/react-dialog";
import { useEffect, useState } from "react";
import { useEffect, useState, ReactNode } from "react";
import { LNURL } from "@snort/shared";
import { NostrEvent, EventPublisher } from "@snort/system";
import { formatSats } from "../number";
@ -15,6 +15,7 @@ interface SendZapsProps {
ev?: NostrEvent;
targetName?: string;
onFinish: () => void;
button?: ReactNode;
}
function SendZaps({ lnurl, ev, targetName, onFinish }: SendZapsProps) {
@ -154,15 +155,20 @@ export function SendZapsDialog({
lnurl,
ev,
targetName,
button,
}: Omit<SendZapsProps, "onFinish">) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button className="btn btn-primary zap">
<span className="hide-on-mobile">Zap</span>
<Icon name="zap" size={16} />
</button>
{button ? (
button
) : (
<button className="btn btn-primary zap">
<span className="hide-on-mobile">Zap</span>
<Icon name="zap" size={16} />
</button>
)}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />

26
src/element/tags.tsx Normal file
View File

@ -0,0 +1,26 @@
import moment from "moment";
import { TaggedRawEvent } from "@snort/system";
import { StreamState } from "index";
import { findTag } from "utils";
export function Tags({ ev }: { ev: TaggedRawEvent }) {
const status = findTag(ev, "status");
const start = findTag(ev, "starts");
return (
<div className="tags">
{status === StreamState.Planned && (
<span className="pill">
Starts {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>
))}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from "react";
import { TaggedRawEvent, validateNostrLink } from "@snort/system";
import { validateNostrLink } from "@snort/system";
import { splitByUrl } from "utils";
import { Emoji } from "./emoji";
import { HyperText } from "./hypertext";
@ -74,11 +74,11 @@ function extractLinks(fragments: Fragment[]) {
.flat();
}
export function Text({ ev }: { ev: TaggedRawEvent }) {
export function Text({ content, tags }: { content: string; tags: string[][] }) {
// todo: RTL langugage support
const element = useMemo(() => {
return <span>{transformText([ev.content], ev.tags)}</span>;
}, [ev]);
return <span>{transformText([content], tags)}</span>;
}, [content, tags]);
return <>{element}</>;
}

View File

@ -1,7 +1,13 @@
import { NostrLink, RequestBuilder, EventKind, FlatNoteStore } from "@snort/system";
import {
NostrLink,
RequestBuilder,
EventKind,
FlatNoteStore,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
import { useMemo } from "react";
import { LIVE_STREAM_CHAT } from "const";
export function useLiveChatFeed(link: NostrLink) {
const sub = useMemo(() => {
@ -10,11 +16,11 @@ export function useLiveChatFeed(link: NostrLink) {
leaveOpen: true,
});
rb.withFilter()
.kinds([EventKind.ZapReceipt, 1311 as EventKind])
.kinds([EventKind.ZapReceipt, LIVE_STREAM_CHAT])
.tag("a", [`${link.kind}:${link.author}:${link.id}`])
.limit(100);
return rb;
}, [link]);
return useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
}
}

View File

@ -2,5 +2,8 @@ import { Login } from "index";
import { useSyncExternalStore } from "react";
export function useLogin() {
return useSyncExternalStore(c => Login.hook(c), () => Login.snapshot());
}
return useSyncExternalStore(
(c) => Login.hook(c),
() => Login.snapshot()
);
}

67
src/hooks/profile.ts Normal file
View File

@ -0,0 +1,67 @@
import { useMemo } from "react";
import {
RequestBuilder,
ReplaceableNoteStore,
FlatNoteStore,
NostrLink,
EventKind,
parseZap,
} from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { LIVE_STREAM } from "const";
import { findTag } from "utils";
import { System } from "index";
export function useProfile(link: NostrLink, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`profile:${link.id.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.kinds([LIVE_STREAM])
.authors([link.id]);
return b;
}, [link, leaveOpen]);
const { data: streamsData } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
);
const streams = Array.isArray(streamsData)
? streamsData
: streamsData
? [streamsData]
: [];
const addresses = useMemo(() => {
return streams.map((e) => `${e.kind}:${e.pubkey}:${findTag(e, "d")}`);
}, [streamsData]);
const zapsSub = useMemo(() => {
const b = new RequestBuilder(`profile-zaps:${link.id.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.kinds([EventKind.ZapReceipt])
.tag("a", addresses);
return b;
}, [link, addresses, leaveOpen]);
const { data: zapsData } = useRequestBuilder<FlatNoteStore>(
System,
FlatNoteStore,
zapsSub
);
const zaps = (zapsData ?? [])
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
.filter((z) => z && z.valid);
return {
streams,
zaps,
};
}

25
src/hooks/top-zappers.ts Normal file
View File

@ -0,0 +1,25 @@
import { useMemo } from "react";
import { ParsedZap } from "@snort/system";
function totalZapped(pubkey: string, zaps: ParsedZap[]) {
return zaps
.filter((z) => (z.anonZap ? pubkey === "anon" : z.sender === pubkey))
.reduce((acc, z) => acc + z.amount, 0);
}
export default function useTopZappers(zaps: ParsedZap[]) {
const zappers = zaps
.map((z) => (z.anonZap ? "anon" : z.sender))
.map((p) => p as string);
const sorted = useMemo(() => {
const pubkeys = [...new Set([...zappers])];
const result = pubkeys.map((pubkey) => {
return { pubkey, total: totalZapped(pubkey, zaps) };
});
result.sort((a, b) => b.total - a.total);
return result;
}, [zaps, zappers]);
return sorted;
}

View File

@ -7,6 +7,7 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { RootPage } from "./pages/root";
import { LayoutPage } from "pages/layout";
import { ProfilePage } from "pages/profile-page";
import { StreamPage } from "pages/stream-page";
import { ChatPopout } from "pages/chat-popout";
import { LoginStore } from "login";
@ -14,7 +15,7 @@ import { LoginStore } from "login";
export enum StreamState {
Live = "live",
Ended = "ended",
Planned = "planned"
Planned = "planned",
}
export const System = new NostrSystem({});
@ -42,7 +43,11 @@ const router = createBrowserRouter([
element: <RootPage />,
},
{
path: "/live/:id",
path: "/p/:npub",
element: <ProfilePage />,
},
{
path: "/:id",
element: <StreamPage />,
},
{

View File

@ -1,29 +1,31 @@
import { ExternalStore } from "@snort/shared";
export interface LoginSession {
pubkey: string
pubkey: string;
follows: string[];
}
export class LoginStore extends ExternalStore<LoginSession | undefined> {
#session?: LoginSession;
#session?: LoginSession;
constructor() {
super();
const json = window.localStorage.getItem("session");
if (json) {
this.#session = JSON.parse(json);
}
constructor() {
super();
const json = window.localStorage.getItem("session");
if (json) {
this.#session = JSON.parse(json);
}
}
loginWithPubkey(pk: string) {
this.#session = {
pubkey: pk
};
window.localStorage.setItem("session", JSON.stringify(this.#session));
this.notifyChange();
}
loginWithPubkey(pk: string) {
this.#session = {
pubkey: pk,
follows: [],
};
window.localStorage.setItem("session", JSON.stringify(this.#session));
this.notifyChange();
}
takeSnapshot() {
return this.#session ? { ...this.#session } : undefined;
}
}
takeSnapshot() {
return this.#session ? { ...this.#session } : undefined;
}
}

View File

@ -11,8 +11,7 @@
height: 100vh;
}
.page.home {
.page.only-content {
display: grid;
height: 100vh;
grid-template-areas:
@ -84,6 +83,10 @@ header {
gap: 24px;
padding: 24px 0 32px 0;
}
.page.only-content {
grid-template-rows: 88px 1fr;
}
}
header .logo {
@ -172,3 +175,12 @@ button span.hide-on-mobile {
max-height: 85vh;
padding: 25px;
}
.zap-icon {
color: #FF8D2B;
}
.tags {
display: flex;
gap: 8px;
}

View File

@ -70,8 +70,8 @@ export function LayoutPage() {
return (
<div
className={
location.pathname === "/"
? "page home"
location.pathname === "/" || location.pathname.startsWith("/p/")
? "page only-content"
: location.pathname.startsWith("/chat/")
? "page chat"
: "page"

199
src/pages/profile-page.css Normal file
View File

@ -0,0 +1,199 @@
.profile-page {
display: flex;
justify-content: center;
}
.profile-page .profile-container {
max-width: 620px;
}
.profile-page .profile-content {
position: relative;
}
.profile-page .banner {
width: 100%;
border-radius: 16px;
}
@media (min-width: 768px){
.profile-page .banner {
max-height: 348.75px;
object-fit: cover;
}
}
.profile-page .avatar {
width: 88px;
height: 88px;
border-radius: 88px;
border: 3px solid #FFF;
object-fit: cover;
margin-left: 16px;
margin-top: -40px;
}
.profile-page .status-indicator {
position: absolute;
top: 16px;
left: 120px;
}
.profile-page .profile-actions {
position: absolute;
display: flex;
gap: 12px;
top: 12px;
right: 12px;
}
.profile-page .profile-information{
margin-left: 16px;
display: flex;
flex-direction: column;
margin-top: 12px;
}
.profile-page .name {
margin: 0;
color: #FFF;
font-size: 21px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
.profile-page .bio {
margin: 0;
color: #ADADAD;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.profile-page .icon-button {
display: flex;
align-items: center;
gap: 8px;
}
.profile-page .icon-button span {
display: none;
}
@media (min-width: 420px) {
.profile-page .icon-button span {
display: block;
}
}
.profile-page .zap-button-icon {
color: #171717;
}
.profile-page .pill.live {
display: flex;
align-items: center;
gap: 8px;
}
.profile-page .pill.offline {
cursor: default;
}
.tabs-root {
display: flex;
flex-direction: column;
margin-top: 20px;
padding: 0 16px;
}
.tabs-list {
flex-shrink: 0;
display: flex;
}
.tabs-tab {
background: black;
background-clip: padding-box;
color: white;
border: 1px solid black;
border-bottom: 1px solid transparent;
position: relative;
cursor: pointer;
height: 52px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
font-size: 16px;
font-family: Outfit;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
@media (max-width: 400px){
.tabs-tab {
font-size: 14px;
}
}
.tabs-tab[data-state='active']:before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
z-index: -1;
margin: -1px;
background: linear-gradient(94.73deg, #2BD9FF 0%, #8C8DED 47.4%, #F838D9 100%);
}
.tabs-content {
flex-grow: 1;
padding-top: 6px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
outline: none;
}
.tabs-content:focus {
box-shadow: 0 0 0 2px black;
}
.profile-page .profile-top-zappers {
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-page .zapper {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.profile-page .zapper .zapper-amount {
display: flex;
align-items: center;
gap: 4px;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: 22px;
}
.profile-page .stream-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.profile-page .stream-item {
display: flex;
flex-direction: column;
gap: 4px;
}

181
src/pages/profile-page.tsx Normal file
View File

@ -0,0 +1,181 @@
import "./profile-page.css";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import * as Tabs from "@radix-ui/react-tabs";
import {
parseNostrLink,
NostrPrefix,
ParsedZap,
encodeTLV,
} from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { Profile } from "element/profile";
import { Icon } from "element/icon";
import { SendZapsDialog } from "element/send-zap";
import { VideoTile } from "element/video-tile";
import { useProfile } from "hooks/profile";
import useTopZappers from "hooks/top-zappers";
import { Text } from "element/text";
import { Tags } from "element/tags";
import { StreamState, System } from "index";
import { findTag } from "utils";
import { formatSats } from "number";
function Zapper({ pubkey, total }: { pubkey: string; total: number }) {
return (
<div className="zapper">
<Profile pubkey={pubkey} />
<div className="zapper-amount">
<Icon name="zap-filled" className="zap-icon" />
<p className="top-zapper-amount">{formatSats(total)}</p>
</div>
</div>
);
}
function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = useTopZappers(zaps);
return (
<section className="profile-top-zappers">
{zappers.map((z) => (
<Zapper key={z.pubkey} pubkey={z.pubkey} total={z.total} />
))}
</section>
);
}
const defaultBanner = "https://void.cat/d/Hn1AdN5UKmceuDkgDW847q.webp";
export function ProfilePage() {
const navigate = useNavigate();
const params = useParams();
const link = parseNostrLink(params.npub!);
const profile = useUserProfile(System, link.id);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const { streams, zaps } = useProfile(link, true);
const liveEvent = useMemo(() => {
return streams.find((ev) => findTag(ev, "status") === StreamState.Live);
}, [streams]);
const pastStreams = useMemo(() => {
return streams.filter((ev) => findTag(ev, "status") === StreamState.Ended);
}, [streams]);
const futureStreams = useMemo(() => {
return streams.filter(
(ev) => findTag(ev, "status") === StreamState.Planned
);
}, [streams]);
const isLive = Boolean(liveEvent);
function goToLive() {
if (liveEvent) {
const d =
liveEvent.tags?.find((t: string[]) => t?.at(0) === "d")?.at(1) || "";
const naddr = encodeTLV(
NostrPrefix.Address,
d,
undefined,
liveEvent.kind,
liveEvent.pubkey
);
navigate(`/${naddr}`);
}
}
// todo: follow
return (
<div className="profile-page">
<div className="profile-container">
<img
className="banner"
alt={profile?.name || link.id}
src={profile?.banner || defaultBanner}
/>
<div className="profile-content">
{profile?.picture && (
<img
className="avatar"
alt={profile.name || link.id}
src={profile.picture}
/>
)}
<div className="status-indicator">
{isLive ? (
<div className="icon-button pill live" onClick={goToLive}>
<Icon name="signal" />
<span>live</span>
</div>
) : (
<span className="pill offline">offline</span>
)}
</div>
<div className="profile-actions">
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}
button={
<button className="btn">
<div className="icon-button">
<span>Zap</span>
<Icon name="zap-filled" className="zap-button-icon" />
</div>
</button>
}
targetName={profile?.name || link.id}
/>
)}
</div>
<div className="profile-information">
{profile?.name && <h1 className="name">{profile.name}</h1>}
{profile?.about && (
<p className="bio">
<Text content={profile.about} tags={[]} />
</p>
)}
</div>
<Tabs.Root className="tabs-root" defaultValue="top-zappers">
<Tabs.List
className="tabs-list"
aria-label={`Information about ${
profile ? profile.name : link.id
}`}
>
<Tabs.Trigger className="tabs-tab" value="top-zappers">
Top Zappers
</Tabs.Trigger>
<Tabs.Trigger className="tabs-tab" value="past-streams">
Past Streams
</Tabs.Trigger>
<Tabs.Trigger className="tabs-tab" value="schedule">
Schedule
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="tabs-content" value="top-zappers">
<TopZappers zaps={zaps} />
</Tabs.Content>
<Tabs.Content className="tabs-content" value="past-streams">
<div className="stream-list">
{pastStreams.map((ev) => (
<div key={ev.id} className="stream-item">
<VideoTile ev={ev} />
<Tags ev={ev} />
</div>
))}
</div>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="schedule">
<div className="stream-list">
{futureStreams.map((ev) => (
<div key={ev.id} className="stream-item">
<VideoTile ev={ev} />
<Tags ev={ev} />
</div>
))}
</div>
</Tabs.Content>
</Tabs.Root>
</div>
</div>
</div>
);
}

View File

@ -2,53 +2,84 @@ import "./root.css";
import { useMemo } from "react";
import { unixNow } from "@snort/shared";
import { EventKind, ParameterizedReplaceableNoteStore, RequestBuilder } from "@snort/system";
import {
ParameterizedReplaceableNoteStore,
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";
export function RootPage() {
const rb = useMemo(() => {
const rb = new RequestBuilder("root");
rb.withOptions({
leaveOpen: true
}).withFilter()
.kinds([30_311 as EventKind])
.since(unixNow() - 86400);
return rb;
}, []);
const rb = useMemo(() => {
const rb = new RequestBuilder("root");
rb.withOptions({
leaveOpen: true,
})
.withFilter()
.kinds([LIVE_STREAM])
.since(unixNow() - 86400);
return rb;
}, []);
const feed = useRequestBuilder<ParameterizedReplaceableNoteStore>(System, ParameterizedReplaceableNoteStore, 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) {
return b.created_at > a.created_at ? 1 : -1;
} else {
return aStatus === "live" ? -1 : 1;
}
});
const feed = useRequestBuilder<ParameterizedReplaceableNoteStore>(
System,
ParameterizedReplaceableNoteStore,
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) {
return b.created_at > a.created_at ? 1 : -1;
} else {
return aStatus === "live" ? -1 : 1;
}
return [];
}, [feed.data])
});
}
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 => 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 && <><h2>Planned</h2>
<div className="video-grid">
{planned.map(e => <VideoTile ev={e} key={e.id} />)}
</div></>}
{ended.length > 0 && <><h2>Ended</h2>
<div className="video-grid">
{ended.map(e => <VideoTile ev={e} key={e.id} />)}
</div></>}
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 && (
<>
<h2>Planned</h2>
<div className="video-grid">
{planned.map((e) => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
)}
{ended.length > 0 && (
<>
<h2>Ended</h2>
<div className="video-grid">
{ended.map((e) => (
<VideoTile ev={e} key={e.id} />
))}
</div>
</>
)}
</div>
}
);
}

View File

@ -43,6 +43,7 @@
.pill.live {
color: inherit;
text-transform: uppercase;
}
@media (min-width: 1020px) {
@ -103,11 +104,6 @@
margin: 0 0 12px 0;
}
.tags {
display: flex;
gap: 8px;
}
.actions {
margin: 8px 0 0 0;
display: flex;

View File

@ -2,7 +2,6 @@ import "./stream-page.css";
import { useRef } from "react";
import { parseNostrLink, EventPublisher } from "@snort/system";
import { useNavigate, useParams } from "react-router-dom";
import moment from "moment";
import useEventFeed from "hooks/event-feed";
import { LiveVideoPlayer } from "element/live-video-player";
@ -11,11 +10,12 @@ import { Profile, getName } from "element/profile";
import { LiveChat } from "element/live-chat";
import AsyncButton from "element/async-button";
import { useLogin } from "hooks/login";
import { StreamState, System } from "index";
import { System } from "index";
import { SendZapsDialog } from "element/send-zap";
import type { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import { NewStreamDialog } from "element/new-stream";
import { Tags } from "element/tags";
function ProfileInfo({ link }: { link: NostrLink }) {
const thisEvent = useEventFeed(link, true);
@ -24,9 +24,6 @@ function ProfileInfo({ link }: { link: NostrLink }) {
const profile = useUserProfile(System, thisEvent.data?.pubkey);
const zapTarget = profile?.lud16 ?? profile?.lud06;
const status = findTag(thisEvent.data, "status");
const start = findTag(thisEvent.data, "starts");
const isLive = status === "live";
const isMine = link.author === login?.pubkey;
async function deleteStream() {
@ -45,22 +42,7 @@ function ProfileInfo({ link }: { link: NostrLink }) {
<div className="f-grow stream-info">
<h1>{findTag(thisEvent.data, "title")}</h1>
<p>{findTag(thisEvent.data, "summary")}</p>
<div className="tags">
<span className={`pill${isLive ? " live" : ""}`}>{status}</span>
{status === StreamState.Planned && (
<span className="pill">
Starts {moment(Number(start) * 1000).fromNow()}
</span>
)}
{thisEvent.data?.tags
.filter((a) => a[0] === "t")
.map((a) => a[1])
.map((a) => (
<span className="pill" key={a}>
{a}
</span>
))}
</div>
{thisEvent?.data && <Tags ev={thisEvent.data} />}
{isMine && (
<div className="actions">
{thisEvent.data && (

View File

@ -1706,6 +1706,17 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-collection@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
@ -1741,6 +1752,13 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-direction@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"
integrity sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
@ -1803,6 +1821,22 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-roving-focus@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
@ -1811,6 +1845,21 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-tabs@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
integrity sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-direction" "1.0.1"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-callback-ref@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"