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,
|
"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",
|
||||||
|
@ -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
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 { 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 />
|
||||||
|
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;
|
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 {
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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
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 { 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}</>;
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,3 @@
|
|||||||
height: 21px;
|
height: 21px;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
|
||||||
}
|
|
||||||
|
@ -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
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 { 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);
|
||||||
}
|
}
|
||||||
|
@ -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
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">
|
<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 |
@ -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 />,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
40
src/login.ts
40
src/login.ts
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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
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 { 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>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
49
yarn.lock
49
yarn.lock
@ -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"
|
||||||
|
Reference in New Issue
Block a user