Merge pull request 'profile page' (#16) from verbiricha/stream:profile into main
Reviewed-on: Kieran/stream#16
This commit is contained in:
@ -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",
|
||||
|
@ -12,7 +12,7 @@
|
||||
<title>Nostr stream</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
4
src/const.ts
Normal file
4
src/const.ts
Normal 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;
|
@ -2,7 +2,8 @@ import "./async-button.css";
|
||||
import { useState } from "react";
|
||||
import Spinner from "element/spinner";
|
||||
|
||||
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface AsyncButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
disabled?: boolean;
|
||||
onClick(e: React.MouseEvent): Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
@ -28,8 +29,15 @@ export default function AsyncButton(props: AsyncButtonProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" disabled={loading || props.disabled} {...props} onClick={handle}>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || props.disabled}
|
||||
{...props}
|
||||
onClick={handle}
|
||||
>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>
|
||||
{props.children}
|
||||
</span>
|
||||
{loading && (
|
||||
<span className="spinner-wrapper">
|
||||
<Spinner />
|
||||
|
66
src/element/follow-button.tsx
Normal file
66
src/element/follow-button.tsx
Normal 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;
|
||||
}
|
@ -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 {
|
||||
|
@ -9,7 +9,6 @@ import {
|
||||
} from "@snort/system";
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
type KeyboardEvent,
|
||||
type ChangeEvent,
|
||||
@ -27,43 +26,29 @@ 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" className="zap-icon" />
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<p className="top-zapper-amount">{formatSats(total)}</p>
|
||||
</div>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -159,7 +144,7 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
|
||||
return (
|
||||
<div className="zap-container">
|
||||
<div className="zap">
|
||||
<Icon name="zap" className="zap-icon" />
|
||||
<Icon name="zap-filled" className="zap-icon" />
|
||||
<Profile
|
||||
pubkey={parsed.anonZap ? "" : parsed.sender ?? ""}
|
||||
options={{
|
||||
@ -167,7 +152,7 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
|
||||
overrideName: parsed.anonZap ? "Anon" : undefined,
|
||||
}}
|
||||
/>
|
||||
zapped you
|
||||
zapped
|
||||
<span className="zap-amount">{formatSats(parsed.amount)}</span>
|
||||
sats
|
||||
</div>
|
||||
@ -239,7 +224,6 @@ function WriteMessage({ link }: { link: NostrLink }) {
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Icon name="message" size={15} />
|
||||
</div>
|
||||
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
|
||||
Send
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
import "./send-zap.css";
|
||||
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 { NostrEvent, EventPublisher } from "@snort/system";
|
||||
import { formatSats } from "../number";
|
||||
import { Icon } from "./icon";
|
||||
import AsyncButton from "./async-button";
|
||||
import { findTag } from "utils";
|
||||
import { Relays } from "index";
|
||||
import QrCode from "./qr-code";
|
||||
|
||||
@ -16,9 +15,16 @@ interface SendZapsProps {
|
||||
aTag?: string;
|
||||
targetName?: string;
|
||||
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 satsAmounts = [
|
||||
@ -156,18 +162,19 @@ export function SendZapsDialog(props: Omit<SendZapsProps, "onFinish">) {
|
||||
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>
|
||||
{props.button ? (
|
||||
props.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" />
|
||||
<Dialog.Content className="dialog-content">
|
||||
<SendZaps
|
||||
{...props}
|
||||
onFinish={() => setIsOpen(false)}
|
||||
/>
|
||||
<SendZaps {...props} onFinish={() => setIsOpen(false)} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
27
src/element/tags.tsx
Normal file
27
src/element/tags.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}</>;
|
||||
}
|
||||
|
@ -34,6 +34,3 @@
|
||||
height: 21px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
}
|
||||
|
@ -6,24 +6,41 @@ import { useInView } from "react-intersection-observer";
|
||||
import { StatePill } from "./state-pill";
|
||||
import { StreamState } from "index";
|
||||
|
||||
export function VideoTile({ ev }: { ev: NostrEvent }) {
|
||||
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;
|
||||
export function VideoTile({
|
||||
ev,
|
||||
showAuthor = true,
|
||||
showStatus = true,
|
||||
}: {
|
||||
ev: NostrEvent;
|
||||
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);
|
||||
return <Link to={`/${link}`} className="video-tile" ref={ref}>
|
||||
<div style={{
|
||||
backgroundImage: `url(${inView ? image : ""})`
|
||||
}}>
|
||||
<StatePill state={status as StreamState} />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<div>
|
||||
{inView && <Profile pubkey={host} />}
|
||||
</div>
|
||||
const link = encodeTLV(
|
||||
NostrPrefix.Address,
|
||||
id,
|
||||
undefined,
|
||||
ev.kind,
|
||||
ev.pubkey
|
||||
);
|
||||
return (
|
||||
<Link to={`/live/${link}`} className="video-tile" ref={ref}>
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(${inView ? image : ""})`,
|
||||
}}
|
||||
>
|
||||
{showStatus && <StatePill state={status as StreamState} />}
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
{showAuthor && <div>{inView && <Profile pubkey={host} />}</div>}
|
||||
</Link>
|
||||
);
|
||||
}
|
28
src/hooks/follows.ts
Normal file
28
src/hooks/follows.ts
Normal 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 };
|
||||
}
|
@ -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,7 +16,7 @@ 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;
|
||||
|
@ -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
67
src/hooks/profile.ts
Normal 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
25
src/hooks/top-zappers.ts
Normal 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;
|
||||
}
|
@ -3,6 +3,9 @@
|
||||
<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" />
|
||||
</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">
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.4 KiB |
@ -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";
|
||||
@ -15,7 +16,7 @@ import { StreamProvidersPage } from "pages/providers";
|
||||
export enum StreamState {
|
||||
Live = "live",
|
||||
Ended = "ended",
|
||||
Planned = "planned"
|
||||
Planned = "planned",
|
||||
}
|
||||
|
||||
export const System = new NostrSystem({});
|
||||
@ -43,8 +44,8 @@ const router = createBrowserRouter([
|
||||
element: <RootPage />,
|
||||
},
|
||||
{
|
||||
path: "/:id",
|
||||
element: <StreamPage />,
|
||||
path: "/p/:npub",
|
||||
element: <ProfilePage />,
|
||||
},
|
||||
{
|
||||
path: "/live/:id",
|
||||
@ -53,7 +54,7 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: "/providers/:id?",
|
||||
element: <StreamProvidersPage />,
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
38
src/login.ts
38
src/login.ts
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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"
|
||||
|
215
src/pages/profile-page.css
Normal file
215
src/pages/profile-page.css
Normal 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
199
src/pages/profile-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
@ -1,8 +1,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";
|
||||
@ -16,18 +14,20 @@ 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";
|
||||
import { StatePill } from "element/state-pill";
|
||||
|
||||
function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
const thisEvent = useEventFeed(link, true);
|
||||
const login = useLogin();
|
||||
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 zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||
|
||||
const status = findTag(thisEvent.data, "status");
|
||||
const start = findTag(thisEvent.data, "starts");
|
||||
const status = thisEvent?.data ? findTag(thisEvent.data, "status") : "";
|
||||
const isMine = link.author === login?.pubkey;
|
||||
|
||||
async function deleteStream() {
|
||||
@ -46,22 +46,8 @@ 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">
|
||||
<StatePill state={status as StreamState} />
|
||||
{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>
|
||||
<StatePill state={status as StreamState} />
|
||||
{thisEvent?.data && <Tags ev={thisEvent.data} />}
|
||||
{isMine && (
|
||||
<div className="actions">
|
||||
{thisEvent.data && (
|
||||
@ -83,7 +69,10 @@ function ProfileInfo({ link }: { link: NostrLink }) {
|
||||
<SendZapsDialog
|
||||
lnurl={zapTarget}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
49
yarn.lock
49
yarn.lock
@ -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"
|
||||
|
Reference in New Issue
Block a user