Merge pull request 'profile page' (#16) from verbiricha/stream:profile into main

Reviewed-on: Kieran/stream#16
This commit is contained in:
2023-07-01 16:57:11 +00:00
30 changed files with 939 additions and 193 deletions

View File

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

View File

@ -12,10 +12,10 @@
<title>Nostr stream</title> <title>Nostr stream</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

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

@ -2,7 +2,8 @@ import "./async-button.css";
import { useState } from "react"; import { useState } from "react";
import Spinner from "element/spinner"; import Spinner from "element/spinner";
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { interface AsyncButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean; disabled?: boolean;
onClick(e: React.MouseEvent): Promise<void> | void; onClick(e: React.MouseEvent): Promise<void> | void;
children?: React.ReactNode; children?: React.ReactNode;
@ -28,8 +29,15 @@ export default function AsyncButton(props: AsyncButtonProps) {
} }
return ( return (
<button type="button" disabled={loading || props.disabled} {...props} onClick={handle}> <button
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span> type="button"
disabled={loading || props.disabled}
{...props}
onClick={handle}
>
<span style={{ visibility: loading ? "hidden" : "visible" }}>
{props.children}
</span>
{loading && ( {loading && (
<span className="spinner-wrapper"> <span className="spinner-wrapper">
<Spinner /> <Spinner />

View File

@ -0,0 +1,66 @@
import { EventKind, EventPublisher } from "@snort/system";
import { useLogin } from "hooks/login";
import useFollows from "hooks/follows";
import AsyncButton from "element/async-button";
import { System } from "index";
export function LoggedInFollowButton({
loggedIn,
pubkey,
}: {
loggedIn: string;
pubkey: string;
}) {
const { contacts, relays } = useFollows(loggedIn, true);
const isFollowing = contacts.find((t) => t.at(1) === pubkey);
async function unfollow() {
const pub = await EventPublisher.nip7();
if (pub) {
const ev = await pub.generic((eb) => {
eb.kind(EventKind.ContactList).content(JSON.stringify(relays));
for (const c of contacts) {
if (c.at(1) !== pubkey) {
eb.tag(c);
}
}
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
}
}
async function follow() {
const pub = await EventPublisher.nip7();
if (pub) {
const ev = await pub.generic((eb) => {
eb.kind(EventKind.ContactList).content(JSON.stringify(relays));
for (const tag of contacts) {
eb.tag(tag);
}
eb.tag(["p", pubkey]);
return eb;
});
console.debug(ev);
System.BroadcastEvent(ev);
}
}
return (
<AsyncButton
type="button"
className="btn btn-primary"
onClick={isFollowing ? unfollow : follow}
>
{isFollowing ? "Unfollow" : "Follow"}
</AsyncButton>
);
}
export function FollowButton({ pubkey }: { pubkey: string }) {
const login = useLogin();
return login?.pubkey ? (
<LoggedInFollowButton loggedIn={login.pubkey} pubkey={pubkey} />
) : null;
}

View File

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

View File

@ -9,7 +9,6 @@ import {
} from "@snort/system"; } from "@snort/system";
import { import {
useState, useState,
useMemo,
useEffect, useEffect,
type KeyboardEvent, type KeyboardEvent,
type ChangeEvent, type ChangeEvent,
@ -27,43 +26,29 @@ import Spinner from "./spinner";
import { useLogin } from "hooks/login"; import { useLogin } from "hooks/login";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { formatSats } from "number"; import { formatSats } from "number";
import useTopZappers from "hooks/top-zappers";
export interface LiveChatOptions { export interface LiveChatOptions {
canWrite?: boolean; canWrite?: boolean;
showHeader?: 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[] }) { function TopZappers({ zaps }: { zaps: ParsedZap[] }) {
const zappers = zaps const zappers = useTopZappers(zaps).slice(0, 3);
.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]);
return ( return (
<> <>
<h3>Top zappers</h3> <h3>Top zappers</h3>
<div className="top-zappers-container"> <div className="top-zappers-container">
{sortedZappers.map((pk, idx) => { {zappers.map(({ pubkey, total }, idx) => {
const total = totalZapped(pk, zaps);
return ( return (
<div className="top-zapper" key={pk}> <div className="top-zapper" key={pubkey}>
{pk === "anon" ? ( {pubkey === "anon" ? (
<p className="top-zapper-name">Anon</p> <p className="top-zapper-name">Anon</p>
) : ( ) : (
<Profile pubkey={pk} options={{ showName: false }} /> <Profile pubkey={pubkey} options={{ showName: false }} />
)} )}
<Icon name="zap" className="zap-icon" /> <Icon name="zap-filled" className="zap-icon" />
<p className="top-zapper-amount">{formatSats(total)}</p> <p className="top-zapper-amount">{formatSats(total)}</p>
</div> </div>
); );
@ -132,7 +117,7 @@ function ChatMessage({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) {
return ( return (
<div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}> <div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}>
<Profile pubkey={ev.pubkey} /> <Profile pubkey={ev.pubkey} />
<Text ev={ev} /> <Text content={ev.content} tags={ev.tags} />
</div> </div>
); );
} }
@ -159,7 +144,7 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
return ( return (
<div className="zap-container"> <div className="zap-container">
<div className="zap"> <div className="zap">
<Icon name="zap" className="zap-icon" /> <Icon name="zap-filled" className="zap-icon" />
<Profile <Profile
pubkey={parsed.anonZap ? "" : parsed.sender ?? ""} pubkey={parsed.anonZap ? "" : parsed.sender ?? ""}
options={{ options={{
@ -167,7 +152,7 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
overrideName: parsed.anonZap ? "Anon" : undefined, overrideName: parsed.anonZap ? "Anon" : undefined,
}} }}
/> />
zapped you zapped
<span className="zap-amount">{formatSats(parsed.amount)}</span> <span className="zap-amount">{formatSats(parsed.amount)}</span>
sats sats
</div> </div>
@ -239,7 +224,6 @@ function WriteMessage({ link }: { link: NostrLink }) {
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onChange={onChange} onChange={onChange}
/> />
<Icon name="message" size={15} />
</div> </div>
<AsyncButton onClick={sendChatMessage} className="btn btn-border"> <AsyncButton onClick={sendChatMessage} className="btn btn-border">
Send Send

View File

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

View File

@ -1,7 +1,9 @@
import "./profile.css"; import "./profile.css";
import { Link } from "react-router-dom";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { UserMetadata } from "@snort/system"; import { UserMetadata } from "@snort/system";
import { hexToBech32 } from "@snort/shared"; import { hexToBech32 } from "@snort/shared";
import { Icon } from "element/icon";
import { System } from "index"; import { System } from "index";
export interface ProfileOptions { export interface ProfileOptions {
@ -12,13 +14,14 @@ export interface ProfileOptions {
} }
export function getName(pk: string, user?: UserMetadata) { export function getName(pk: string, user?: UserMetadata) {
const shortPubkey = hexToBech32("npub", pk).slice(0, 12); const npub = hexToBech32("npub", pk);
if ((user?.display_name?.length ?? 0) > 0) { const shortPubkey = npub.slice(0, 12);
return user?.display_name;
}
if ((user?.name?.length ?? 0) > 0) { if ((user?.name?.length ?? 0) > 0) {
return user?.name; return user?.name;
} }
if ((user?.display_name?.length ?? 0) > 0) {
return user?.display_name;
}
return shortPubkey; return shortPubkey;
} }
@ -33,17 +36,32 @@ export function Profile({
}) { }) {
const profile = useUserProfile(System, pubkey); const profile = useUserProfile(System, pubkey);
return ( const content = (
<div className="profile"> <>
{(options?.showAvatar ?? true) && ( {(options?.showAvatar ?? true) && pubkey === "anon" ? (
<Icon size={40} name="zap-filled" />
) : (
<img <img
alt={profile?.name || pubkey} alt={profile?.name || pubkey}
className={avatarClassname ? avatarClassname : ""} className={avatarClassname ? avatarClassname : ""}
src={profile?.picture ?? ""} src={profile?.picture ?? ""}
/> />
)} )}
{(options?.showName ?? true) && {(options?.showName ?? true) && (
(options?.overrideName ?? getName(pubkey, profile))} <span>
</div> {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,12 +1,11 @@
import "./send-zap.css"; import "./send-zap.css";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import { useEffect, useState } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { LNURL } from "@snort/shared"; import { LNURL } from "@snort/shared";
import { NostrEvent, EventPublisher } from "@snort/system"; import { NostrEvent, EventPublisher } from "@snort/system";
import { formatSats } from "../number"; import { formatSats } from "../number";
import { Icon } from "./icon"; import { Icon } from "./icon";
import AsyncButton from "./async-button"; import AsyncButton from "./async-button";
import { findTag } from "utils";
import { Relays } from "index"; import { Relays } from "index";
import QrCode from "./qr-code"; import QrCode from "./qr-code";
@ -16,9 +15,16 @@ interface SendZapsProps {
aTag?: string; aTag?: string;
targetName?: string; targetName?: string;
onFinish: () => void; onFinish: () => void;
button?: ReactNode;
} }
function SendZaps({ lnurl, pubkey, aTag, targetName, onFinish }: SendZapsProps) { function SendZaps({
lnurl,
pubkey,
aTag,
targetName,
onFinish,
}: SendZapsProps) {
const UsdRate = 30_000; const UsdRate = 30_000;
const satsAmounts = [ const satsAmounts = [
@ -156,18 +162,19 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
return ( return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}> <Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<button className="btn btn-primary zap"> {props.button ? (
<span className="hide-on-mobile">Zap</span> props.button
<Icon name="zap" size={16} /> ) : (
</button> <button className="btn btn-primary zap">
<span className="hide-on-mobile">Zap</span>
<Icon name="zap" size={16} />
</button>
)}
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" /> <Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content"> <Dialog.Content className="dialog-content">
<SendZaps <SendZaps {...props} onFinish={() => setIsOpen(false)} />
{...props}
onFinish={() => setIsOpen(false)}
/>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>

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

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

View File

@ -34,6 +34,3 @@
height: 21px; height: 21px;
border-radius: 100%; border-radius: 100%;
} }
.user-details {
}

View File

@ -6,24 +6,41 @@ import { useInView } from "react-intersection-observer";
import { StatePill } from "./state-pill"; import { StatePill } from "./state-pill";
import { StreamState } from "index"; import { StreamState } from "index";
export function VideoTile({ ev }: { ev: NostrEvent }) { export function VideoTile({
const { inView, ref } = useInView({ triggerOnce: true }); ev,
const id = ev.tags.find(a => a[0] === "d")?.[1]!; showAuthor = true,
const title = ev.tags.find(a => a[0] === "title")?.[1]; showStatus = true,
const image = ev.tags.find(a => a[0] === "image")?.[1]; }: {
const status = ev.tags.find(a => a[0] === "status")?.[1]; ev: NostrEvent;
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey; showAuthor?: boolean;
showStatus?: boolean;
}) {
const { inView, ref } = useInView({ triggerOnce: true });
const id = ev.tags.find((a) => a[0] === "d")?.[1]!;
const title = ev.tags.find((a) => a[0] === "title")?.[1];
const image = ev.tags.find((a) => a[0] === "image")?.[1];
const status = ev.tags.find((a) => a[0] === "status")?.[1];
const host =
ev.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;
const link = encodeTLV(NostrPrefix.Address, id, undefined, ev.kind, ev.pubkey); const link = encodeTLV(
return <Link to={`/${link}`} className="video-tile" ref={ref}> NostrPrefix.Address,
<div style={{ id,
backgroundImage: `url(${inView ? image : ""})` undefined,
}}> ev.kind,
<StatePill state={status as StreamState} /> ev.pubkey
</div> );
<h3>{title}</h3> return (
<div> <Link to={`/live/${link}`} className="video-tile" ref={ref}>
{inView && <Profile pubkey={host} />} <div
</div> style={{
backgroundImage: `url(${inView ? image : ""})`,
}}
>
{showStatus && <StatePill state={status as StreamState} />}
</div>
<h3>{title}</h3>
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
</Link> </Link>
} );
}

28
src/hooks/follows.ts Normal file
View File

@ -0,0 +1,28 @@
import { useMemo } from "react";
import { EventKind, ReplaceableNoteStore, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { System } from "index";
export default function useFollows(pubkey: string, leaveOpen = false) {
const sub = useMemo(() => {
const b = new RequestBuilder(`follows:${pubkey.slice(0, 12)}`);
b.withOptions({
leaveOpen,
})
.withFilter()
.authors([pubkey])
.kinds([EventKind.ContactList]);
return b;
}, [pubkey, leaveOpen]);
const { data } = useRequestBuilder<ReplaceableNoteStore>(
System,
ReplaceableNoteStore,
sub
);
const contacts = (data?.tags ?? []).filter((t) => t.at(0) === "p");
const relays = JSON.parse(data?.content ?? "{}");
return { contacts, relays };
}

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

View File

@ -2,5 +2,8 @@ import { Login } from "index";
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
export function useLogin() { 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

@ -3,6 +3,9 @@
<symbol id="zap" viewBox="0 0 16 20" fill="none"> <symbol id="zap" viewBox="0 0 16 20" fill="none">
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" /> <path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</symbol> </symbol>
<symbol id="zap-filled" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.3983 1.08269C13.8055 1.25946 14.0474 1.68353 13.9924 2.12403L13.1329 9L19.3279 8.99999C19.5689 8.99995 19.813 8.9999 20.0124 9.01796C20.201 9.03503 20.5622 9.08021 20.8754 9.33332C21.234 9.62308 21.4394 10.0616 21.4324 10.5226C21.4263 10.9253 21.2298 11.2316 21.1222 11.3875C21.0084 11.5522 20.8521 11.7397 20.6978 11.9248L11.7683 22.6402C11.4841 22.9812 11.0091 23.0941 10.6019 22.9173C10.1947 22.7405 9.95277 22.3165 10.0078 21.876L10.8673 15L4.67233 15C4.43134 15 4.18725 15.0001 3.98782 14.982C3.79921 14.965 3.43805 14.9198 3.12483 14.6667C2.76626 14.3769 2.56085 13.9383 2.5678 13.4774C2.57387 13.0747 2.77038 12.7684 2.878 12.6125C2.9918 12.4478 3.14811 12.2603 3.30242 12.0752C3.31007 12.066 3.31771 12.0568 3.32534 12.0477L12.2319 1.35981C12.5161 1.01878 12.9911 0.905925 13.3983 1.08269Z" fill="currentColor"/>
</symbol>
<symbol id="search" viewBox="0 0 20 21" fill="none"> <symbol id="search" viewBox="0 0 20 21" fill="none">
<path d="M19 19.5L14.65 15.15M17 9.5C17 13.9183 13.4183 17.5 9 17.5C4.58172 17.5 1 13.9183 1 9.5C1 5.08172 4.58172 1.5 9 1.5C13.4183 1.5 17 5.08172 17 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M19 19.5L14.65 15.15M17 9.5C17 13.9183 13.4183 17.5 9 17.5C4.58172 17.5 1 13.9183 1 9.5C1 5.08172 4.58172 1.5 9 1.5C13.4183 1.5 17 5.08172 17 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</symbol> </symbol>
@ -19,4 +22,4 @@
<path d="M15.2426 4.75735C17.5858 7.1005 17.5858 10.8995 15.2426 13.2426M6.75736 13.2426C4.41421 10.8995 4.41421 7.10046 6.75736 4.75732M3.92893 16.0711C0.0236893 12.1658 0.0236893 5.83417 3.92893 1.92892M18.0711 1.92897C21.9763 5.83421 21.9763 12.1659 18.0711 16.0711M13 8.99999C13 10.1046 12.1046 11 11 11C9.89543 11 9 10.1046 9 8.99999C9 7.89542 9.89543 6.99999 11 6.99999C12.1046 6.99999 13 7.89542 13 8.99999Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M15.2426 4.75735C17.5858 7.1005 17.5858 10.8995 15.2426 13.2426M6.75736 13.2426C4.41421 10.8995 4.41421 7.10046 6.75736 4.75732M3.92893 16.0711C0.0236893 12.1658 0.0236893 5.83417 3.92893 1.92892M18.0711 1.92897C21.9763 5.83421 21.9763 12.1659 18.0711 16.0711M13 8.99999C13 10.1046 12.1046 11 11 11C9.89543 11 9 10.1046 9 8.99999C9 7.89542 9.89543 6.99999 11 6.99999C12.1046 6.99999 13 7.89542 13 8.99999Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol> </symbol>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,215 @@
.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 {
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: 12px;
margin-left: 16px;
display: flex;
flex-direction: column;
}
.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: 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;
}
.stream-item .video-tile h3 {
font-size: 20px;
font-style: normal;
font-weight: 600;
line-height: normal;
margin: 6px 0 0 0;
}
.stream-item .timestamp {
color: #ADADAD;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

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

@ -0,0 +1,199 @@
import "./profile-page.css";
import { useMemo } from "react";
import moment from "moment";
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 { FollowButton } from "element/follow-button";
import { useProfile } from "hooks/profile";
import useTopZappers from "hooks/top-zappers";
import { Text } from "element/text";
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(`/live/${naddr}`);
}
}
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
aTag={
liveEvent
? `${liveEvent.kind}:${liveEvent.pubkey}:${findTag(
liveEvent,
"d"
)}`
: undefined
}
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}
/>
)}
<FollowButton pubkey={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} showAuthor={false} showStatus={false} />
<span className="timestamp">
Streamed on{" "}
{moment(Number(ev.created_at) * 1000).format(
"MMM DD, YYYY"
)}
</span>
</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} showAuthor={false} showStatus={false} />
<span className="timestamp">
Scheduled for{" "}
{moment(Number(ev.created_at) * 1000).format(
"MMM DD, YYYY h:mm:ss a"
)}
</span>
</div>
))}
</div>
</Tabs.Content>
</Tabs.Root>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -1,8 +1,6 @@
import "./stream-page.css"; import "./stream-page.css";
import { useRef } from "react";
import { parseNostrLink, EventPublisher } from "@snort/system"; import { parseNostrLink, EventPublisher } from "@snort/system";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import moment from "moment";
import useEventFeed from "hooks/event-feed"; import useEventFeed from "hooks/event-feed";
import { LiveVideoPlayer } from "element/live-video-player"; import { LiveVideoPlayer } from "element/live-video-player";
@ -16,18 +14,20 @@ import { SendZapsDialog } from "element/send-zap";
import type { NostrLink } from "@snort/system"; import type { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { NewStreamDialog } from "element/new-stream"; import { NewStreamDialog } from "element/new-stream";
import { Tags } from "element/tags";
import { StatePill } from "element/state-pill"; import { StatePill } from "element/state-pill";
function ProfileInfo({ link }: { link: NostrLink }) { function ProfileInfo({ link }: { link: NostrLink }) {
const thisEvent = useEventFeed(link, true); const thisEvent = useEventFeed(link, true);
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const host = thisEvent.data?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? thisEvent.data?.pubkey; const host =
thisEvent.data?.tags.find((a) => a[0] === "p" && a[3] === "host")?.[1] ??
thisEvent.data?.pubkey;
const profile = useUserProfile(System, host); const profile = useUserProfile(System, host);
const zapTarget = profile?.lud16 ?? profile?.lud06; const zapTarget = profile?.lud16 ?? profile?.lud06;
const status = findTag(thisEvent.data, "status"); const status = thisEvent?.data ? findTag(thisEvent.data, "status") : "";
const start = findTag(thisEvent.data, "starts");
const isMine = link.author === login?.pubkey; const isMine = link.author === login?.pubkey;
async function deleteStream() { async function deleteStream() {
@ -46,22 +46,8 @@ function ProfileInfo({ link }: { link: NostrLink }) {
<div className="f-grow stream-info"> <div className="f-grow stream-info">
<h1>{findTag(thisEvent.data, "title")}</h1> <h1>{findTag(thisEvent.data, "title")}</h1>
<p>{findTag(thisEvent.data, "summary")}</p> <p>{findTag(thisEvent.data, "summary")}</p>
<div className="tags"> <StatePill state={status as StreamState} />
<StatePill state={status as StreamState} /> {thisEvent?.data && <Tags ev={thisEvent.data} />}
{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>
{isMine && ( {isMine && (
<div className="actions"> <div className="actions">
{thisEvent.data && ( {thisEvent.data && (
@ -83,7 +69,10 @@ function ProfileInfo({ link }: { link: NostrLink }) {
<SendZapsDialog <SendZapsDialog
lnurl={zapTarget} lnurl={zapTarget}
pubkey={host} pubkey={host}
aTag={`${thisEvent.data.kind}:${thisEvent.data.pubkey}:${findTag(thisEvent.data, "d")}`} aTag={`${thisEvent.data.kind}:${thisEvent.data.pubkey}:${findTag(
thisEvent.data,
"d"
)}`}
targetName={getName(thisEvent.data.pubkey, profile)} targetName={getName(thisEvent.data.pubkey, profile)}
/> />
)} )}

View File

@ -1706,6 +1706,17 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@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": "@radix-ui/react-compose-refs@1.0.1":
version "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" 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" aria-hidden "^1.1.1"
react-remove-scroll "2.5.5" 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": "@radix-ui/react-dismissable-layer@1.0.4":
version "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" 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" "@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.2" "@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": "@radix-ui/react-slot@1.0.2":
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" 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" "@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1" "@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": "@radix-ui/react-use-callback-ref@1.0.1":
version "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" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"