Merge pull request 'feat: show chat message zaps and reactions' (#19) from verbiricha/stream:message-zaps into main
Reviewed-on: Kieran/stream#19
This commit is contained in:
@ -3,6 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.1.2",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@react-hook/resize-observer": "^1.2.6",
|
"@react-hook/resize-observer": "^1.2.6",
|
||||||
@ -13,6 +15,7 @@
|
|||||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"emoji-mart": "^5.5.2",
|
||||||
"hls.js": "^1.4.6",
|
"hls.js": "^1.4.6",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
@ -22,6 +25,7 @@
|
|||||||
"react-intersection-observer": "^9.5.1",
|
"react-intersection-observer": "^9.5.1",
|
||||||
"react-router-dom": "^6.13.0",
|
"react-router-dom": "^6.13.0",
|
||||||
"semantic-sdp": "^3.26.2",
|
"semantic-sdp": "^3.26.2",
|
||||||
|
"usehooks-ts": "^2.9.1",
|
||||||
"web-vitals": "^2.1.0",
|
"web-vitals": "^2.1.0",
|
||||||
"webrtc-adapter": "^8.2.3"
|
"webrtc-adapter": "^8.2.3"
|
||||||
},
|
},
|
||||||
|
@ -92,6 +92,7 @@
|
|||||||
|
|
||||||
.live-chat .message {
|
.live-chat .message {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-chat .message .profile {
|
.live-chat .message .profile {
|
||||||
@ -191,7 +192,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background: black;
|
background: #0A0A0A;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
@ -217,3 +218,123 @@
|
|||||||
.zap-content {
|
.zap-content {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zap-pill {
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0px 4px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-pill-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: #FF8D2B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-zap-container {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #303030;
|
||||||
|
background: #111;
|
||||||
|
box-shadow: 0px 7px 4px 0px rgba(0, 0, 0, 0.25);
|
||||||
|
margin-top: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
z-index: 1;
|
||||||
|
transition: opacity .3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1020px) {
|
||||||
|
.message-zap-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-zap-button {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 24px;
|
||||||
|
padding: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #FFFFFF66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-zap-button:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-zap-button-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reactions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-container {
|
||||||
|
display: flex;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0px 4px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction {
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-pill-amount {
|
||||||
|
text-transform: lowercase;
|
||||||
|
color: #FFF;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: Outfit;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-composer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.write-message-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.write-message-container .paper {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.write-emoji-button {
|
||||||
|
color: #FFFFFF80;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.write-emoji-button:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
@ -10,11 +10,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useHover, useOnClickOutside, useMediaQuery } from "usehooks-ts";
|
||||||
|
|
||||||
import useEmoji from "hooks/emoji";
|
import data from "@emoji-mart/data";
|
||||||
|
import Picker from "@emoji-mart/react";
|
||||||
|
import useEmoji, { type EmojiPack } from "hooks/emoji";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
import { useLiveChatFeed } from "hooks/live-chat";
|
import { useLiveChatFeed } from "hooks/live-chat";
|
||||||
import AsyncButton from "./async-button";
|
import AsyncButton from "./async-button";
|
||||||
@ -23,10 +29,79 @@ import { Icon } from "./icon";
|
|||||||
import { Text } from "./text";
|
import { Text } from "./text";
|
||||||
import { Textarea } from "./textarea";
|
import { Textarea } from "./textarea";
|
||||||
import Spinner from "./spinner";
|
import Spinner from "./spinner";
|
||||||
|
import { SendZapsDialog } from "./send-zap";
|
||||||
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";
|
import useTopZappers from "hooks/top-zappers";
|
||||||
|
import { LIVE_STREAM_CHAT } from "const";
|
||||||
|
import { findTag } from "utils";
|
||||||
|
|
||||||
|
interface EmojiPickerProps {
|
||||||
|
topOffset: number;
|
||||||
|
leftOffset: number;
|
||||||
|
emojiPacks?: EmojiPack[];
|
||||||
|
onEmojiSelect: (e: Emoji) => void;
|
||||||
|
onClickOutside: () => void;
|
||||||
|
height?: number;
|
||||||
|
ref: RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmojiPicker({
|
||||||
|
topOffset,
|
||||||
|
leftOffset,
|
||||||
|
onEmojiSelect,
|
||||||
|
onClickOutside,
|
||||||
|
emojiPacks = [],
|
||||||
|
height = 300,
|
||||||
|
ref,
|
||||||
|
}: EmojiPickerProps) {
|
||||||
|
const customEmojiList = emojiPacks.map((pack) => {
|
||||||
|
return {
|
||||||
|
id: pack.address,
|
||||||
|
name: pack.name,
|
||||||
|
emojis: pack.emojis.map((e) => {
|
||||||
|
const [, name, url] = e;
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
skins: [{ src: url }],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: topOffset - height - 10,
|
||||||
|
left: leftOffset,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
em-emoji-picker { max-height: ${height}px; }
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<Picker
|
||||||
|
autoFocus
|
||||||
|
data={data}
|
||||||
|
custom={customEmojiList}
|
||||||
|
perLine={7}
|
||||||
|
previewPosition="none"
|
||||||
|
skinTonePosition="search"
|
||||||
|
theme="dark"
|
||||||
|
onEmojiSelect={onEmojiSelect}
|
||||||
|
onClickOutside={onClickOutside}
|
||||||
|
maxFrequentRows={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiveChatOptions {
|
export interface LiveChatOptions {
|
||||||
canWrite?: boolean;
|
canWrite?: boolean;
|
||||||
@ -67,13 +142,18 @@ export function LiveChat({
|
|||||||
options?: LiveChatOptions;
|
options?: LiveChatOptions;
|
||||||
height?: number;
|
height?: number;
|
||||||
}) {
|
}) {
|
||||||
const messages = useLiveChatFeed(link);
|
const feed = useLiveChatFeed(link);
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const events = messages.data ?? [];
|
const zaps = feed.zaps
|
||||||
const zaps = events
|
|
||||||
.filter((ev) => ev.kind === EventKind.ZapReceipt)
|
.filter((ev) => ev.kind === EventKind.ZapReceipt)
|
||||||
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||||
.filter((z) => z && z.valid);
|
.filter((z) => z && z.valid);
|
||||||
|
const events = useMemo(() => {
|
||||||
|
return [...feed.messages, ...feed.zaps].sort(
|
||||||
|
(a, b) => b.created_at - a.created_at
|
||||||
|
);
|
||||||
|
}, [feed.messages, feed.zaps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
<div className="live-chat" style={height ? { height: `${height}px` } : {}}>
|
||||||
{(options?.showHeader ?? true) && (
|
{(options?.showHeader ?? true) && (
|
||||||
@ -85,20 +165,26 @@ export function LiveChat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
{[...(messages.data ?? [])]
|
{events.map((a) => {
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
.map((a) => {
|
|
||||||
switch (a.kind) {
|
switch (a.kind) {
|
||||||
case 1311: {
|
case LIVE_STREAM_CHAT: {
|
||||||
return <ChatMessage ev={a} link={link} key={a.id} />;
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
streamer={link.author ?? ""}
|
||||||
|
ev={a}
|
||||||
|
link={link}
|
||||||
|
key={a.id}
|
||||||
|
reactions={feed.reactions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case EventKind.ZapReceipt: {
|
case EventKind.ZapReceipt: {
|
||||||
return <ChatZap ev={a} key={a.id} />;
|
return <ChatZap streamer={link.author ?? ""} ev={a} key={a.id} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
{messages.data === undefined && <Spinner />}
|
{feed.messages.length === 0 && <Spinner />}
|
||||||
</div>
|
</div>
|
||||||
{(options?.canWrite ?? true) && (
|
{(options?.canWrite ?? true) && (
|
||||||
<div className="write-message">
|
<div className="write-message">
|
||||||
@ -113,16 +199,164 @@ export function LiveChat({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatMessage({ ev, link }: { ev: TaggedRawEvent; link: NostrLink }) {
|
function emojifyReaction(reaction: string) {
|
||||||
|
if (reaction === "+") {
|
||||||
|
return "💜";
|
||||||
|
}
|
||||||
|
if (reaction === "-") {
|
||||||
|
return "👎";
|
||||||
|
}
|
||||||
|
return reaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emoji {
|
||||||
|
id: string;
|
||||||
|
native?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatMessage({
|
||||||
|
streamer,
|
||||||
|
ev,
|
||||||
|
link,
|
||||||
|
reactions,
|
||||||
|
}: {
|
||||||
|
streamer: string;
|
||||||
|
ev: TaggedRawEvent;
|
||||||
|
link: NostrLink;
|
||||||
|
reactions: readonly TaggedRawEvent[];
|
||||||
|
}) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const emojiRef = useRef(null);
|
||||||
|
const isTablet = useMediaQuery("(max-width: 1020px)");
|
||||||
|
const isHovering = useHover(ref);
|
||||||
|
const [showZapDialog, setShowZapDialog] = useState(false);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
const profile = useUserProfile(System, ev.pubkey);
|
||||||
|
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||||
|
const zaps = reactions
|
||||||
|
.filter((ev) => ev.kind === EventKind.ZapReceipt)
|
||||||
|
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||||
|
.filter((z) => z && z.valid);
|
||||||
|
const emojis = useMemo(() => {
|
||||||
|
const emojified = reactions
|
||||||
|
.filter((e) => e.kind === EventKind.Reaction && findTag(e, "e") === ev.id)
|
||||||
|
.map((ev) => emojifyReaction(ev.content));
|
||||||
|
return [...new Set(emojified)];
|
||||||
|
}, [ev, reactions]);
|
||||||
|
|
||||||
|
const hasReactions = emojis.length > 0;
|
||||||
|
const totalZaps = useMemo(() => {
|
||||||
|
const messageZaps = zaps.filter((z) => z.event === ev.id);
|
||||||
|
return messageZaps.reduce((acc, z) => acc + z.amount, 0);
|
||||||
|
}, [reactions, ev]);
|
||||||
|
const hasZaps = totalZaps > 0;
|
||||||
|
|
||||||
|
useOnClickOutside(ref, () => {
|
||||||
|
setShowZapDialog(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
useOnClickOutside(emojiRef, () => {
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onEmojiSelect(emoji: Emoji) {
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
setShowZapDialog(false);
|
||||||
|
try {
|
||||||
|
const pub = await EventPublisher.nip7();
|
||||||
|
const reply = await pub?.react(ev, emoji.native || "+1");
|
||||||
|
if (reply) {
|
||||||
|
console.debug(reply);
|
||||||
|
System.BroadcastEvent(reply);
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
const topOffset = ref.current?.getBoundingClientRect().top;
|
||||||
|
// @ts-expect-error
|
||||||
|
const leftOffset = ref.current?.getBoundingClientRect().left;
|
||||||
|
|
||||||
|
function pickEmoji(ev: any) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setShowEmojiPicker(!showEmojiPicker);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message${link.author === ev.pubkey ? " streamer" : ""}`}>
|
<>
|
||||||
|
<div
|
||||||
|
className={`message${link.author === ev.pubkey ? " streamer" : ""}`}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => setShowZapDialog(true)}
|
||||||
|
>
|
||||||
<Profile pubkey={ev.pubkey} />
|
<Profile pubkey={ev.pubkey} />
|
||||||
<Text content={ev.content} tags={ev.tags} />
|
<Text content={ev.content} tags={ev.tags} />
|
||||||
|
{(hasReactions || hasZaps) && (
|
||||||
|
<div className="message-reactions">
|
||||||
|
{hasZaps && (
|
||||||
|
<div className="zap-pill">
|
||||||
|
<Icon name="zap-filled" className="zap-pill-icon" />
|
||||||
|
<span className="zap-pill-amount">{formatSats(totalZaps)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{emojis.map((e) => (
|
||||||
|
<div className="message-reaction-container">
|
||||||
|
<span className="message-reaction">{e}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ref.current && (
|
||||||
|
<div
|
||||||
|
className="message-zap-container"
|
||||||
|
style={
|
||||||
|
isTablet
|
||||||
|
? {
|
||||||
|
display: showZapDialog || isHovering ? "flex" : "none",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: "fixed",
|
||||||
|
top: topOffset - 12,
|
||||||
|
left: leftOffset - 32,
|
||||||
|
opacity: showZapDialog || isHovering ? 1 : 0,
|
||||||
|
pointerEvents:
|
||||||
|
showZapDialog || isHovering ? "auto" : "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{zapTarget && (
|
||||||
|
<SendZapsDialog
|
||||||
|
lnurl={zapTarget}
|
||||||
|
eTag={ev.id}
|
||||||
|
pubkey={ev.pubkey}
|
||||||
|
button={
|
||||||
|
<button className="message-zap-button">
|
||||||
|
<Icon name="zap" className="message-zap-button-icon" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
targetName={profile?.name || ev.pubkey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button className="message-zap-button" onClick={pickEmoji}>
|
||||||
|
<Icon name="face" className="message-zap-button-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<EmojiPicker
|
||||||
|
topOffset={topOffset}
|
||||||
|
leftOffset={leftOffset}
|
||||||
|
onEmojiSelect={onEmojiSelect}
|
||||||
|
onClickOutside={() => setShowEmojiPicker(false)}
|
||||||
|
ref={emojiRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatZap({ ev }: { ev: TaggedRawEvent }) {
|
function ChatZap({ streamer, ev }: { streamer: string; ev: TaggedRawEvent }) {
|
||||||
const parsed = parseZap(ev, System.ProfileLoader.Cache);
|
const parsed = parseZap(ev, System.ProfileLoader.Cache);
|
||||||
useUserProfile(System, parsed.anonZap ? undefined : parsed.sender);
|
useUserProfile(System, parsed.anonZap ? undefined : parsed.sender);
|
||||||
|
|
||||||
@ -141,12 +375,13 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
|
|||||||
if (!parsed.valid) {
|
if (!parsed.valid) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
|
return parsed.receiver === streamer ? (
|
||||||
<div className="zap-container">
|
<div className="zap-container">
|
||||||
<div className="zap">
|
<div className="zap">
|
||||||
<Icon name="zap-filled" className="zap-icon" />
|
<Icon name="zap-filled" className="zap-icon" />
|
||||||
<Profile
|
<Profile
|
||||||
pubkey={parsed.anonZap ? "" : parsed.sender ?? ""}
|
pubkey={parsed.anonZap ? "anon" : parsed.sender ?? "anon"}
|
||||||
options={{
|
options={{
|
||||||
showAvatar: !parsed.anonZap,
|
showAvatar: !parsed.anonZap,
|
||||||
overrideName: parsed.anonZap ? "Anon" : undefined,
|
overrideName: parsed.anonZap ? "Anon" : undefined,
|
||||||
@ -158,16 +393,26 @@ function ChatZap({ ev }: { ev: TaggedRawEvent }) {
|
|||||||
</div>
|
</div>
|
||||||
{parsed.content && <div className="zap-content">{parsed.content}</div>}
|
{parsed.content && <div className="zap-content">{parsed.content}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WriteMessage({ link }: { link: NostrLink }) {
|
function WriteMessage({ link }: { link: NostrLink }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const emojiRef = useRef(null);
|
||||||
const [chat, setChat] = useState("");
|
const [chat, setChat] = useState("");
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const userEmojis = useEmoji(login!.pubkey);
|
const userEmojiPacks = useEmoji(login!.pubkey);
|
||||||
const channelEmojis = useEmoji(link.author!);
|
const userEmojis = userEmojiPacks.map((pack) => pack.emojis).flat();
|
||||||
|
const channelEmojiPacks = useEmoji(link.author!);
|
||||||
|
const channelEmojis = channelEmojiPacks.map((pack) => pack.emojis).flat();
|
||||||
const emojis = userEmojis.concat(channelEmojis);
|
const emojis = userEmojis.concat(channelEmojis);
|
||||||
const names = emojis.map((t) => t.at(1));
|
const names = emojis.map((t) => t.at(1));
|
||||||
|
const allEmojiPacks = userEmojiPacks.concat(channelEmojiPacks);
|
||||||
|
// @ts-expect-error
|
||||||
|
const topOffset = ref.current?.getBoundingClientRect().top;
|
||||||
|
// @ts-expect-error
|
||||||
|
const leftOffset = ref.current?.getBoundingClientRect().left;
|
||||||
|
|
||||||
async function sendChatMessage() {
|
async function sendChatMessage() {
|
||||||
const pub = await EventPublisher.nip7();
|
const pub = await EventPublisher.nip7();
|
||||||
@ -184,7 +429,7 @@ function WriteMessage({ link }: { link: NostrLink }) {
|
|||||||
const emoji = [...emojiNames].map((name) =>
|
const emoji = [...emojiNames].map((name) =>
|
||||||
emojis.find((e) => e.at(1) === name)
|
emojis.find((e) => e.at(1) === name)
|
||||||
);
|
);
|
||||||
eb.kind(1311 as EventKind)
|
eb.kind(LIVE_STREAM_CHAT as EventKind)
|
||||||
.content(chat)
|
.content(chat)
|
||||||
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
|
.tag(["a", `${link.kind}:${link.author}:${link.id}`, "", "root"])
|
||||||
.processContent();
|
.processContent();
|
||||||
@ -203,6 +448,15 @@ function WriteMessage({ link }: { link: NostrLink }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onEmojiSelect(emoji: Emoji) {
|
||||||
|
if (emoji.native) {
|
||||||
|
setChat(`${chat}${emoji.native}`);
|
||||||
|
} else {
|
||||||
|
setChat(`${chat}:${emoji.id}:`);
|
||||||
|
}
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function onKeyDown(e: KeyboardEvent) {
|
async function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.code === "Enter") {
|
if (e.code === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -215,15 +469,33 @@ function WriteMessage({ link }: { link: NostrLink }) {
|
|||||||
setChat(e.target.value);
|
setChat(e.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickEmoji(ev: any) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setShowEmojiPicker(!showEmojiPicker);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="paper">
|
<div className="paper" ref={ref}>
|
||||||
<Textarea
|
<Textarea
|
||||||
emojis={emojis}
|
emojis={emojis}
|
||||||
value={chat}
|
value={chat}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
|
<div onClick={pickEmoji}>
|
||||||
|
<Icon name="face" className="write-emoji-button" />
|
||||||
|
</div>
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<EmojiPicker
|
||||||
|
topOffset={topOffset}
|
||||||
|
leftOffset={leftOffset}
|
||||||
|
emojiPacks={allEmojiPacks}
|
||||||
|
onEmojiSelect={onEmojiSelect}
|
||||||
|
onClickOutside={() => setShowEmojiPicker(false)}
|
||||||
|
ref={emojiRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
|
<AsyncButton onClick={sendChatMessage} className="btn btn-border">
|
||||||
Send
|
Send
|
||||||
|
@ -35,10 +35,13 @@ export function Profile({
|
|||||||
options?: ProfileOptions;
|
options?: ProfileOptions;
|
||||||
}) {
|
}) {
|
||||||
const profile = useUserProfile(System, pubkey);
|
const profile = useUserProfile(System, pubkey);
|
||||||
|
const showAvatar = options?.showAvatar ?? true;
|
||||||
|
const showName = options?.showName ?? true;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{(options?.showAvatar ?? true) && pubkey === "anon" ? (
|
{showAvatar &&
|
||||||
|
(pubkey === "anon" ? (
|
||||||
<Icon size={40} name="zap-filled" />
|
<Icon size={40} name="zap-filled" />
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
@ -46,8 +49,8 @@ export function Profile({
|
|||||||
className={avatarClassname ? avatarClassname : ""}
|
className={avatarClassname ? avatarClassname : ""}
|
||||||
src={profile?.picture ?? ""}
|
src={profile?.picture ?? ""}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
{(options?.showName ?? true) && (
|
{showName && (
|
||||||
<span>
|
<span>
|
||||||
{options?.overrideName ?? pubkey === "anon"
|
{options?.overrideName ?? pubkey === "anon"
|
||||||
? "Anon"
|
? "Anon"
|
||||||
|
@ -20,6 +20,7 @@ export interface SendZapsProps {
|
|||||||
lnurl: string | LNURLLike;
|
lnurl: string | LNURLLike;
|
||||||
pubkey?: string;
|
pubkey?: string;
|
||||||
aTag?: string;
|
aTag?: string;
|
||||||
|
eTag?: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
button?: ReactNode;
|
button?: ReactNode;
|
||||||
@ -29,6 +30,7 @@ export function SendZaps({
|
|||||||
lnurl,
|
lnurl,
|
||||||
pubkey,
|
pubkey,
|
||||||
aTag,
|
aTag,
|
||||||
|
eTag,
|
||||||
targetName,
|
targetName,
|
||||||
onFinish,
|
onFinish,
|
||||||
}: SendZapsProps) {
|
}: SendZapsProps) {
|
||||||
@ -68,7 +70,7 @@ export function SendZaps({
|
|||||||
|
|
||||||
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
|
const amountInSats = isFiat ? Math.floor((amount / UsdRate) * 1e8) : amount;
|
||||||
let zap: NostrEvent | undefined;
|
let zap: NostrEvent | undefined;
|
||||||
if (pubkey && aTag) {
|
if (pubkey) {
|
||||||
zap = await pub.zap(
|
zap = await pub.zap(
|
||||||
amountInSats * 1000,
|
amountInSats * 1000,
|
||||||
pubkey,
|
pubkey,
|
||||||
@ -76,7 +78,13 @@ export function SendZaps({
|
|||||||
undefined,
|
undefined,
|
||||||
comment,
|
comment,
|
||||||
(eb) => {
|
(eb) => {
|
||||||
return eb.tag(["a", aTag]);
|
if (aTag) {
|
||||||
|
eb.tag(["a", aTag]);
|
||||||
|
}
|
||||||
|
if (eTag) {
|
||||||
|
eb.tag(["e", eTag]);
|
||||||
|
}
|
||||||
|
return eb;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { TaggedRawEvent } from "@snort/system";
|
import { TaggedRawEvent } from "@snort/system";
|
||||||
import { StreamState } from "index";
|
import { StreamState } from "index";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
|
|
||||||
export function Tags({ ev }: { ev: TaggedRawEvent }) {
|
export function Tags({
|
||||||
|
children,
|
||||||
|
ev,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
ev: TaggedRawEvent;
|
||||||
|
}) {
|
||||||
const status = findTag(ev, "status");
|
const status = findTag(ev, "status");
|
||||||
const start = findTag(ev, "starts");
|
const start = findTag(ev, "starts");
|
||||||
return (
|
return (
|
||||||
<div className="tags">
|
<div className="tags">
|
||||||
|
{children}
|
||||||
{status === StreamState.Planned && (
|
{status === StreamState.Planned && (
|
||||||
<span className="pill">
|
<span className="pill">
|
||||||
{status === StreamState.Planned ? "Starts " : ""}
|
{status === StreamState.Planned ? "Starts " : ""}
|
||||||
|
@ -1,29 +1,42 @@
|
|||||||
import { RequestBuilder, EventKind, FlatNoteStore } from "@snort/system";
|
import {
|
||||||
|
RequestBuilder,
|
||||||
|
EventKind,
|
||||||
|
ReplaceableNoteStore,
|
||||||
|
ParameterizedReplaceableNoteStore,
|
||||||
|
} 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 { findTag } from "utils";
|
||||||
import type { EmojiTag } from "../element/emoji";
|
import type { EmojiTag } from "../element/emoji";
|
||||||
|
|
||||||
|
export interface EmojiPack {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
emojis: EmojiTag[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function useEmoji(pubkey: string) {
|
export default function useEmoji(pubkey: string) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const rb = new RequestBuilder(`emoji:${pubkey}`);
|
const rb = new RequestBuilder(`emoji:${pubkey}`);
|
||||||
|
|
||||||
rb.withFilter()
|
rb.withFilter()
|
||||||
.authors([pubkey])
|
.authors([pubkey])
|
||||||
.kinds([10030 as EventKind, 30030 as EventKind]);
|
.kinds([10030 as EventKind]);
|
||||||
|
|
||||||
return rb;
|
return rb;
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
const { data } = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
|
const { data: userEmoji } = useRequestBuilder<ReplaceableNoteStore>(
|
||||||
const userEmoji = useMemo(() => {
|
System,
|
||||||
return data ?? [];
|
ReplaceableNoteStore,
|
||||||
}, [data]);
|
sub
|
||||||
|
);
|
||||||
|
|
||||||
const related = useMemo(() => {
|
const related = useMemo(() => {
|
||||||
if (userEmoji) {
|
if (userEmoji) {
|
||||||
const tags = userEmoji.at(0)?.tags ?? [];
|
return userEmoji.tags.filter(
|
||||||
return tags.filter(
|
|
||||||
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`30030:`)
|
(t) => t.at(0) === "a" && t.at(1)?.startsWith(`30030:`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -46,28 +59,32 @@ export default function useEmoji(pubkey: string) {
|
|||||||
rb.withFilter()
|
rb.withFilter()
|
||||||
.kinds([30030 as EventKind])
|
.kinds([30030 as EventKind])
|
||||||
.authors(authors)
|
.authors(authors)
|
||||||
// @ts-expect-error
|
.tag("d", identifiers);
|
||||||
.tag(["d", identifiers]);
|
|
||||||
|
|
||||||
return rb;
|
return rb;
|
||||||
}, [pubkey, related]);
|
}, [pubkey, related]);
|
||||||
|
|
||||||
const { data: relatedData } = useRequestBuilder<FlatNoteStore>(
|
const { data: relatedData } =
|
||||||
|
useRequestBuilder<ParameterizedReplaceableNoteStore>(
|
||||||
System,
|
System,
|
||||||
FlatNoteStore,
|
ParameterizedReplaceableNoteStore,
|
||||||
subRelated
|
subRelated
|
||||||
);
|
);
|
||||||
|
|
||||||
const emojiPacks = useMemo(() => {
|
const emojiPacks = useMemo(() => {
|
||||||
return relatedData ?? [];
|
return relatedData ?? [];
|
||||||
}, [relatedData]);
|
}, [relatedData]);
|
||||||
|
|
||||||
const emojis = useMemo(() => {
|
const emojis = useMemo(() => {
|
||||||
return userEmoji
|
return emojiPacks.map((ev) => {
|
||||||
.concat(emojiPacks)
|
const d = findTag(ev, "d");
|
||||||
.map((ev) => {
|
return {
|
||||||
return ev.tags.filter((t) => t.at(0) === "emoji");
|
address: `${ev.kind}:${ev.pubkey}:${d}`,
|
||||||
})
|
name: d,
|
||||||
.flat() as EmojiTag[];
|
author: ev.pubkey,
|
||||||
|
emojis: ev.tags.filter((t) => t.at(0) === "emoji") as EmojiTag[],
|
||||||
|
} as EmojiPack;
|
||||||
|
});
|
||||||
}, [userEmoji, emojiPacks]);
|
}, [userEmoji, emojiPacks]);
|
||||||
|
|
||||||
return emojis;
|
return emojis;
|
||||||
|
@ -22,5 +22,38 @@ export function useLiveChatFeed(link: NostrLink) {
|
|||||||
return rb;
|
return rb;
|
||||||
}, [link]);
|
}, [link]);
|
||||||
|
|
||||||
return useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
|
const feed = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
|
||||||
|
|
||||||
|
const messages = useMemo(() => {
|
||||||
|
return (feed.data ?? []).filter((ev) => ev.kind === LIVE_STREAM_CHAT);
|
||||||
|
}, [feed.data]);
|
||||||
|
const zaps = useMemo(() => {
|
||||||
|
return (feed.data ?? []).filter((ev) => ev.kind === EventKind.ZapReceipt);
|
||||||
|
}, [feed.data]);
|
||||||
|
|
||||||
|
const etags = useMemo(() => {
|
||||||
|
return messages.map((e) => e.id);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const esub = useMemo(() => {
|
||||||
|
if (etags.length === 0) return null;
|
||||||
|
const rb = new RequestBuilder(`reactions:${link.id}:${link.author}`);
|
||||||
|
rb.withOptions({
|
||||||
|
leaveOpen: true,
|
||||||
|
});
|
||||||
|
rb.withFilter()
|
||||||
|
.kinds([EventKind.Reaction, EventKind.ZapReceipt])
|
||||||
|
.tag("e", etags);
|
||||||
|
return rb;
|
||||||
|
}, [etags]);
|
||||||
|
|
||||||
|
const reactionsSub = useRequestBuilder<FlatNoteStore>(
|
||||||
|
System,
|
||||||
|
FlatNoteStore,
|
||||||
|
esub
|
||||||
|
);
|
||||||
|
|
||||||
|
const reactions = reactionsSub.data ?? [];
|
||||||
|
|
||||||
|
return { messages, zaps, reactions };
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,9 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
|
|||||||
.withFilter()
|
.withFilter()
|
||||||
.kinds([LIVE_STREAM])
|
.kinds([LIVE_STREAM])
|
||||||
.authors([link.id]);
|
.authors([link.id]);
|
||||||
|
|
||||||
|
b.withFilter().kinds([LIVE_STREAM]).tag("p", [link.id]);
|
||||||
|
|
||||||
return b;
|
return b;
|
||||||
}, [link, leaveOpen]);
|
}, [link, leaveOpen]);
|
||||||
|
|
||||||
@ -57,10 +60,16 @@ export function useProfile(link: NostrLink, leaveOpen = false) {
|
|||||||
);
|
);
|
||||||
const zaps = (zapsData ?? [])
|
const zaps = (zapsData ?? [])
|
||||||
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
.map((ev) => parseZap(ev, System.ProfileLoader.Cache))
|
||||||
.filter((z) => z && z.valid);
|
.filter((z) => z && z.valid && z.receiver === link.id);
|
||||||
|
|
||||||
|
const sortedStreams = useMemo(() => {
|
||||||
|
const sorted = [...streams];
|
||||||
|
sorted.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
return sorted;
|
||||||
|
}, [streams]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streams,
|
streams: sortedStreams,
|
||||||
zaps,
|
zaps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -21,5 +21,8 @@
|
|||||||
<symbol id="signal" viewBox="0 0 22 18" fill="none">
|
<symbol id="signal" viewBox="0 0 22 18" fill="none">
|
||||||
<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>
|
||||||
|
<symbol id="face" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M15 9H15.01M9 9H9.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM15.5 9C15.5 9.27614 15.2761 9.5 15 9.5C14.7239 9.5 14.5 9.27614 14.5 9C14.5 8.72386 14.7239 8.5 15 8.5C15.2761 8.5 15.5 8.72386 15.5 9ZM9.5 9C9.5 9.27614 9.27614 9.5 9 9.5C8.72386 9.5 8.5 9.27614 8.5 9C8.5 8.72386 8.72386 8.5 9 8.5C9.27614 8.5 9.5 8.72386 9.5 9ZM12 17.5C14.5005 17.5 16.5 15.667 16.5 14H7.5C7.5 15.667 9.4995 17.5 12 17.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</symbol>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 5.1 KiB |
@ -47,10 +47,6 @@ const router = createBrowserRouter([
|
|||||||
path: "/p/:npub",
|
path: "/p/:npub",
|
||||||
element: <ProfilePage />,
|
element: <ProfilePage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/live/:id",
|
|
||||||
element: <StreamPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/:id",
|
path: "/:id",
|
||||||
element: <StreamPage />,
|
element: <StreamPage />,
|
||||||
|
@ -161,9 +161,11 @@ button span.hide-on-mobile {
|
|||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-content {
|
.dialog-content {
|
||||||
|
z-index: 2;
|
||||||
background-color: #171717;
|
background-color: #171717;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
@ -119,10 +119,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabs-tab {
|
.tabs-tab {
|
||||||
background: black;
|
background: #0A0A0A;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid black;
|
border: 1px solid #0A0A0A;
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -137,18 +137,24 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@media (max-width: 400px){
|
@media (max-width: 400px){
|
||||||
.tabs-tab {
|
.tabs-tab {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabs-tab[data-state='active']:before {
|
|
||||||
content: '';
|
.tab-border {
|
||||||
position: absolute;
|
height: 1px;
|
||||||
top: 0; right: 0; bottom: 0; left: 0;
|
margin-top: 12px;
|
||||||
z-index: -1;
|
background: transparent;
|
||||||
margin: -1px;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-tab[data-state='active'] .tab-border {
|
||||||
|
height: 1px;
|
||||||
background: linear-gradient(94.73deg, #2BD9FF 0%, #8C8DED 47.4%, #F838D9 100%);
|
background: linear-gradient(94.73deg, #2BD9FF 0%, #8C8DED 47.4%, #F838D9 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,12 +149,15 @@ export function ProfilePage() {
|
|||||||
>
|
>
|
||||||
<Tabs.Trigger className="tabs-tab" value="top-zappers">
|
<Tabs.Trigger className="tabs-tab" value="top-zappers">
|
||||||
Top Zappers
|
Top Zappers
|
||||||
|
<div className="tab-border"></div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger className="tabs-tab" value="past-streams">
|
<Tabs.Trigger className="tabs-tab" value="past-streams">
|
||||||
Past Streams
|
Past Streams
|
||||||
|
<div className="tab-border"></div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger className="tabs-tab" value="schedule">
|
<Tabs.Trigger className="tabs-tab" value="schedule">
|
||||||
Schedule
|
Schedule
|
||||||
|
<div className="tab-border"></div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content className="tabs-content" value="top-zappers">
|
<Tabs.Content className="tabs-content" value="top-zappers">
|
||||||
|
@ -46,8 +46,11 @@ 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>
|
||||||
|
{thisEvent?.data && (
|
||||||
|
<Tags ev={thisEvent.data}>
|
||||||
<StatePill state={status as StreamState} />
|
<StatePill state={status as StreamState} />
|
||||||
{thisEvent?.data && <Tags ev={thisEvent.data} />}
|
</Tags>
|
||||||
|
)}
|
||||||
{isMine && (
|
{isMine && (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{thisEvent.data && (
|
{thisEvent.data && (
|
||||||
|
20
yarn.lock
20
yarn.lock
@ -1028,6 +1028,16 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
|
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
|
||||||
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
|
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
|
||||||
|
|
||||||
|
"@emoji-mart/data@^1.1.2":
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.1.2.tgz#777c976f8f143df47cbb23a7077c9ca9fe5fc513"
|
||||||
|
integrity sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==
|
||||||
|
|
||||||
|
"@emoji-mart/react@^1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
|
||||||
|
integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
|
||||||
|
|
||||||
"@eslint-community/eslint-utils@^4.2.0":
|
"@eslint-community/eslint-utils@^4.2.0":
|
||||||
version "4.4.0"
|
version "4.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
|
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
|
||||||
@ -3127,6 +3137,11 @@ electron-to-chromium@^1.4.431:
|
|||||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.433.tgz#305ef5f8ea5fe65d252aae4b0e1088f9e4842533"
|
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.433.tgz#305ef5f8ea5fe65d252aae4b0e1088f9e4842533"
|
||||||
integrity sha512-MGO1k0w1RgrfdbLVwmXcDhHHuxCn2qRgR7dYsJvWFKDttvYPx6FNzCGG0c/fBBvzK2LDh3UV7Tt9awnHnvAAUQ==
|
integrity sha512-MGO1k0w1RgrfdbLVwmXcDhHHuxCn2qRgR7dYsJvWFKDttvYPx6FNzCGG0c/fBBvzK2LDh3UV7Tt9awnHnvAAUQ==
|
||||||
|
|
||||||
|
emoji-mart@^5.5.2:
|
||||||
|
version "5.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.5.2.tgz#3ddbaf053139cf4aa217650078bc1c50ca8381af"
|
||||||
|
integrity sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==
|
||||||
|
|
||||||
emoji-regex@10.2.1, emoji-regex@^10.2.1:
|
emoji-regex@10.2.1, emoji-regex@^10.2.1:
|
||||||
version "10.2.1"
|
version "10.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.2.1.tgz#a41c330d957191efd3d9dfe6e1e8e1e9ab048b3f"
|
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.2.1.tgz#a41c330d957191efd3d9dfe6e1e8e1e9ab048b3f"
|
||||||
@ -6323,6 +6338,11 @@ use-sidecar@^1.1.2:
|
|||||||
detect-node-es "^1.1.0"
|
detect-node-es "^1.1.0"
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
|
usehooks-ts@^2.9.1:
|
||||||
|
version "2.9.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.9.1.tgz#953d3284851ffd097432379e271ce046a8180b37"
|
||||||
|
integrity sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==
|
||||||
|
|
||||||
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
|
Reference in New Issue
Block a user