reorganize code into smaller files & dirs

This commit is contained in:
Martti Malmi
2024-01-04 15:48:19 +02:00
parent 5ea2eb711f
commit afa6d39a56
321 changed files with 671 additions and 671 deletions

View File

@ -0,0 +1,28 @@
.spinner-wrapper {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.spinner-button > span {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.light .spinner-button {
border: 1px solid var(--border-color);
color: var(--font-secondary);
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;
}
.light .spinner-button:hover {
box-shadow: rgba(0, 0, 0, 0.2) 0 1px 3px;
}
.light .spinner-button > span {
color: black;
}

View File

@ -0,0 +1,34 @@
import "./AsyncButton.css";
import React, { ForwardedRef } from "react";
import Spinner from "@/Components/Icons/Spinner";
import useLoading from "@/Hooks/useLoading";
import classNames from "classnames";
export interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onClick?: (e: React.MouseEvent) => Promise<void> | void;
}
const AsyncButton = React.forwardRef<HTMLButtonElement, AsyncButtonProps>((props, ref) => {
const { handle, loading } = useLoading(props.onClick, props.disabled);
return (
<button
ref={ref as ForwardedRef<HTMLButtonElement>}
type="button"
disabled={loading || props.disabled}
{...props}
className={classNames("spinner-button", props.className)}
onClick={handle}>
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
{loading && (
<span className="spinner-wrapper">
<Spinner />
</span>
)}
</button>
);
});
AsyncButton.displayName = "AsyncButton";
export default AsyncButton;

View File

@ -0,0 +1,24 @@
import Icon from "@/Components/Icons/Icon";
import useLoading from "@/Hooks/useLoading";
import Spinner from "@/Components/Icons/Spinner";
export type AsyncIconProps = React.HTMLProps<HTMLDivElement> & {
iconName: string;
iconSize?: number;
onClick?: (e: React.MouseEvent) => Promise<void> | void;
};
export function AsyncIcon(props: AsyncIconProps) {
const { loading, handle } = useLoading(props.onClick, props.disabled);
const mergedProps = { ...props } as Record<string, unknown>;
delete mergedProps["iconName"];
delete mergedProps["iconSize"];
delete mergedProps["loading"];
return (
<div {...mergedProps} onClick={handle} className={props.className}>
{loading ? <Spinner /> : <Icon name={props.iconName} size={props.iconSize} />}
{props.children}
</div>
);
}

View File

@ -0,0 +1,22 @@
.back-button {
background: none;
padding: 0;
color: var(--highlight);
font-weight: 400;
font-size: var(--font-size);
display: flex;
align-items: center;
border: none !important;
box-shadow: none !important;
}
.back-button svg {
margin-right: 0.5em;
}
.back-button:hover:hover,
.light .back-button:hover {
text-decoration: underline;
box-shadow: none !important;
background: none !important;
}

View File

@ -0,0 +1,29 @@
import "./BackButton.css";
import { useIntl } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import messages from "../messages";
interface BackButtonProps {
text?: string;
onClick?(): void;
}
const BackButton = ({ text, onClick }: BackButtonProps) => {
const { formatMessage } = useIntl();
const onClickHandler = () => {
if (onClick) {
onClick();
}
};
return (
<button className="back-button" type="button" onClick={onClickHandler}>
<Icon name="arrowBack" />
{text || formatMessage(messages.Back)}
</button>
);
};
export default BackButton;

View File

@ -0,0 +1,15 @@
import Icon from "@/Components/Icons/Icon";
import classNames from "classnames";
export default function CloseButton({ onClick, className }: { onClick?: () => void; className?: string }) {
return (
<div
onClick={onClick}
className={classNames(
"self-center circle flex flex-shrink-0 flex-grow-0 items-center justify-center hover:opacity-80 bg-dark p-2 cursor-pointer",
className,
)}>
<Icon name="close" size={12} />
</div>
);
}

View File

@ -0,0 +1,21 @@
import classNames from "classnames";
import Icon, { IconProps } from "@/Components/Icons/Icon";
import type { ReactNode } from "react";
interface IconButtonProps {
onClick?: () => void;
icon: IconProps;
className?: string;
children?: ReactNode;
}
const IconButton = ({ onClick, icon, children, className }: IconButtonProps) => {
return (
<button className={classNames("icon", className)} type="button" onClick={onClick}>
<Icon {...icon} />
{children}
</button>
);
};
export default IconButton;

View File

@ -0,0 +1,24 @@
import { FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { logout } from "@/Utils/Login";
import useLogin from "@/Hooks/useLogin";
import messages from "../messages";
export default function LogoutButton() {
const navigate = useNavigate();
const login = useLogin(s => ({ publicKey: s.publicKey, id: s.id }));
if (!login.publicKey) return;
return (
<button
className="secondary"
type="button"
onClick={() => {
logout(login.id);
navigate("/");
}}>
<FormattedMessage {...messages.Logout} />
</button>
);
}

View File

@ -0,0 +1,20 @@
import { NavLink as RouterNavLink, NavLinkProps, useLocation } from "react-router-dom";
export default function NavLink(props: NavLinkProps) {
const { to, onClick, ...rest } = props;
const location = useLocation();
const isActive = location.pathname === to.toString();
const handleClick = event => {
if (onClick) {
onClick(event);
}
if (isActive) {
window.scrollTo({ top: 0, behavior: "instant" });
}
};
return <RouterNavLink to={to} onClick={handleClick} {...rest} />;
}

View File

@ -0,0 +1,57 @@
import { useState, ReactNode } from "react";
import classNames from "classnames";
import Icon from "@/Components/Icons/Icon";
import ShowMore from "@/Components/Event/ShowMore";
interface CollapsedProps {
text?: string;
children: ReactNode;
collapsed: boolean;
setCollapsed(b: boolean): void;
}
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => {
return collapsed ? (
<div className="collapsed">
<ShowMore text={text} onClick={() => setCollapsed(false)} />
</div>
) : (
<div className="uncollapsed">{children}</div>
);
};
interface CollapsedIconProps {
icon: ReactNode;
collapsed: boolean;
}
export const CollapsedIcon = ({ icon, collapsed }: CollapsedIconProps) => {
return collapsed ? <div className="collapsed">{icon}</div> : <div className="uncollapsed">{icon}</div>;
};
interface CollapsedSectionProps {
title: ReactNode;
children: ReactNode;
className?: string;
}
export const CollapsedSection = ({ title, children, className }: CollapsedSectionProps) => {
const [collapsed, setCollapsed] = useState(true);
const icon = (
<div className={classNames("collapse-icon", { flip: !collapsed })}>
<Icon name="arrowFront" />
</div>
);
return (
<>
<div className={classNames("collapsable-section", className)} onClick={() => setCollapsed(!collapsed)}>
{title}
<CollapsedIcon icon={icon} collapsed={collapsed} />
</div>
{!collapsed && children}
</>
);
};
export default Collapsed;

View File

@ -0,0 +1,54 @@
export default function AwardIcon({ size }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 62 62" fill="none" className="award">
<defs>
<linearGradient
id="paint0_linear_2660_40043"
x1="31"
y1="3.57143"
x2="31"
y2="58.4286"
gradientUnits="userSpaceOnUse">
<stop stopColor="#5B2CB3" />
<stop offset="1" stopColor="#811EFF" />
</linearGradient>
<linearGradient
id="paint1_linear_2660_40043"
x1="15.5594"
y1="24.305"
x2="46.433"
y2="24.305"
gradientUnits="userSpaceOnUse">
<stop stopColor="#AC88FF" />
<stop offset="1" stopColor="#7234FF" />
</linearGradient>
</defs>
<g id="award-02">
<rect x="1.85713" y="1.85714" width="58.2857" height="58.2857" rx="29.1429" fill="#AC88FF" fillOpacity="0.2" />
<rect
x="1.85713"
y="1.85714"
width="58.2857"
height="58.2857"
rx="29.1429"
stroke="url(#paint0_linear_2660_40043)"
strokeWidth="3.42857"
/>
<path
id="Solid"
d="M23.2006 52.4983L22.5639 50.9066L23.2006 52.4983L30.9963 49.38L38.7919 52.4983C39.8813 52.934 41.116 52.801 42.0876 52.1432C43.0592 51.4854 43.6412 50.3885 43.6412 49.2151V38.1015C46.467 35.038 48.1957 30.9408 48.1957 26.4427C48.1957 16.9437 40.4952 9.24329 30.9963 9.24329C21.4973 9.24329 13.7968 16.9437 13.7968 26.4427C13.7968 30.9408 15.5255 35.038 18.3513 38.1015V49.2151C18.3513 50.3885 18.9333 51.4854 19.9049 52.1432C20.8765 52.801 22.1112 52.934 23.2006 52.4983ZM27.2967 43.2429L25.4234 43.9922V42.7187C26.0332 42.9275 26.6584 43.1029 27.2967 43.2429ZM34.6958 43.2429C35.3341 43.1029 35.9593 42.9275 36.5691 42.7187V43.9922L34.6958 43.2429Z"
fill="url(#paint1_linear_2660_40043)"
stroke="#251250"
strokeWidth="3.42857"
strokeLinecap="round"
/>
<path
id="Ellipse 1595"
d="M24.2557 14.6002C17.7766 18.3409 15.5567 26.6257 19.2974 33.1049L42.7604 19.5585C39.0196 13.0794 30.7348 10.8595 24.2557 14.6002Z"
fill="white"
fillOpacity="0.1"
/>
</g>
</svg>
);
}

View File

@ -0,0 +1,48 @@
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import AwardIcon from "./Award";
import Modal from "../Modal/Modal";
import { Link } from "react-router-dom";
import CloseButton from "../Button/CloseButton";
export function LeaderBadge() {
const [showModal, setShowModal] = useState(false);
return (
<>
<div
className="flex gap-1 p-1 pr-2 items-center border border-[#5B2CB3] rounded-full"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowModal(true);
}}>
<AwardIcon size={16} />
<div className="text-xs font-medium text-[#AC88FF]">
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
</div>
</div>
{showModal && (
<Modal onClose={() => setShowModal(false)} id="leaders">
<div className="flex flex-col gap-4 items-center relative">
<CloseButton className="absolute right-2 top-2" onClick={() => setShowModal(false)} />
<AwardIcon size={80} />
<div className="text-3xl font-semibold">
<FormattedMessage defaultMessage="Community Leader" id="7YkSA2" />
</div>
<p className="text-secondary">
<FormattedMessage
defaultMessage="Community leaders are individuals who grow the nostr ecosystem by being active in their local communities and helping onboard new users. Anyone can become a community leader, but few hold the current honorary title."
id="f1OxTe"
/>
</p>
<Link to="/settings/invite">
<button className="primary">
<FormattedMessage defaultMessage="Become a leader" id="M6C/px" />
</button>
</Link>
</div>
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,4 @@
.copy .copy-body {
font-size: var(--font-size-small);
color: var(--font-color);
}

View File

@ -0,0 +1,24 @@
import "./Copy.css";
import classNames from "classnames";
import Icon from "@/Components/Icons/Icon";
import { useCopy } from "@/Hooks/useCopy";
export interface CopyProps {
text: string;
maxSize?: number;
className?: string;
}
export default function Copy({ text, maxSize = 32, className }: CopyProps) {
const { copy, copied } = useCopy();
const sliceLength = maxSize / 2;
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
return (
<div className={classNames("copy flex pointer g8 items-center", className)} onClick={() => copy(text)}>
<span className="copy-body">{trimmed}</span>
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}
</span>
</div>
);
}

View File

@ -0,0 +1,16 @@
const AppleMusicEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace("music.apple.com", "embed.music.apple.com");
const isSongLink = /\?i=\d+$/.test(convertedUrl);
return (
<iframe
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
frameBorder="0"
height={isSongLink ? 175 : 450}
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}></iframe>
);
};
export default AppleMusicEmbed;

View File

@ -0,0 +1,8 @@
.cashu {
background: var(--cashu-gradient);
}
.cashu h1 {
font-size: 44px;
line-height: 1em;
}

View File

@ -0,0 +1,139 @@
import "./CashuNuts.css";
import { useEffect, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Components/Icons/Icon";
interface Token {
token: Array<{
mint: string;
proofs: Array<{
amount: number;
}>;
}>;
memo?: string;
}
export default function CashuNuts({ token }: { token: string }) {
const { publicKey } = useLogin(s => ({ publicKey: s.publicKey }));
const profile = useUserProfile(publicKey);
async function copyToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
e.stopPropagation();
await navigator.clipboard.writeText(token);
}
async function redeemToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
e.stopPropagation();
const lnurl = profile?.lud16 ?? "";
const url = `https://redeem.cashu.me?token=${encodeURIComponent(token)}&lightning=${encodeURIComponent(
lnurl,
)}&autopay=yes`;
window.open(url, "_blank");
}
const [cashu, setCashu] = useState<Token>();
useEffect(() => {
try {
if (!token.startsWith("cashuA") || token.length < 10) {
return;
}
import("@cashu/cashu-ts").then(({ getDecodedToken }) => {
const tkn = getDecodedToken(token);
setCashu(tkn);
});
} catch {
// ignored
}
}, [token]);
if (!cashu) return <>{token}</>;
const amount = cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
return (
<div className="cashu flex justify-between p24 br">
<div className="flex flex-col g8 f-ellipsis">
<div className="flex items-center g16">
<svg width="30" height="39" viewBox="0 0 30 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 47711">
<path
id="Rectangle 585"
d="M29.3809 2.47055L29.3809 11.7277L26.7913 11.021C23.8493 10.2181 20.727 10.3835 17.8863 11.4929C15.5024 12.4238 12.9113 12.6933 10.3869 12.2728L7.11501 11.7277L7.11501 2.47054L10.3869 3.01557C12.9113 3.43607 15.5024 3.1666 17.8863 2.23566C20.727 1.12632 23.8493 0.960876 26.7913 1.7638L29.3809 2.47055Z"
fill="url(#paint0_linear_1976_19241)"
/>
<path
id="Rectangle 587"
d="M29.3809 27.9803L29.3809 37.2375L26.7913 36.5308C23.8493 35.7278 20.727 35.8933 17.8863 37.0026C15.5024 37.9336 12.9113 38.203 10.3869 37.7825L7.11501 37.2375L7.11501 27.9803L10.3869 28.5253C12.9113 28.9458 15.5024 28.6764 17.8863 27.7454C20.727 26.6361 23.8493 26.4706 26.7913 27.2736L29.3809 27.9803Z"
fill="url(#paint1_linear_1976_19241)"
/>
<path
id="Rectangle 586"
d="M8.494e-08 15.2069L4.89585e-07 24.4641L2.5896 23.7573C5.53159 22.9544 8.6539 23.1198 11.4946 24.2292C13.8784 25.1601 16.4695 25.4296 18.9939 25.0091L22.2658 24.4641L22.2658 15.2069L18.9939 15.7519C16.4695 16.1724 13.8784 15.9029 11.4946 14.972C8.6539 13.8627 5.53159 13.6972 2.5896 14.5001L8.494e-08 15.2069Z"
fill="url(#paint2_linear_1976_19241)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_1976_19241"
x1="29.3809"
y1="6.7213"
x2="7.11501"
y2="6.7213"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="paint1_linear_1976_19241"
x1="29.3809"
y1="32.2311"
x2="7.11501"
y2="32.2311"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
<linearGradient
id="paint2_linear_1976_19241"
x1="2.70746e-07"
y1="19.4576"
x2="22.2658"
y2="19.4576"
gradientUnits="userSpaceOnUse">
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0.5" />
</linearGradient>
</defs>
</svg>
<FormattedMessage
defaultMessage="<h1>{n}</h1> Cashu sats"
id="6/SF6e"
values={{
h1: c => <h1>{c}</h1>,
n: <FormattedNumber value={amount} />,
}}
/>
</div>
<small className="xs w-max">
<FormattedMessage
defaultMessage="<b>Mint:</b> {url}"
id="zwb6LR"
values={{
b: c => <b>{c}</b>,
url: new URL(cashu.token[0].mint).hostname,
}}
/>
</small>
</div>
<div className="flex g8">
<button onClick={e => copyToken(e, token)}>
<Icon name="copy" />
</button>
<button onClick={e => redeemToken(e, token)}>
<FormattedMessage defaultMessage="Redeem" id="XrSk2j" description="Button: Redeem Cashu token" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
.hashtag {
color: var(--highlight);
}

View File

@ -0,0 +1,14 @@
import { Link } from "react-router-dom";
import "./Hashtag.css";
const Hashtag = ({ tag }: { tag: string }) => {
return (
<span className="hashtag">
<Link to={`/t/${tag}`} onClick={e => e.stopPropagation()}>
#{tag}
</Link>
</span>
);
};
export default Hashtag;

View File

@ -0,0 +1,99 @@
import {
YoutubeUrlRegex,
TidalRegex,
SoundCloudRegex,
MixCloudRegex,
SpotifyRegex,
TwitchRegex,
AppleMusicRegex,
NostrNestsRegex,
WavlakeRegex,
} from "@/Utils/Const";
import { magnetURIDecode } from "@/Utils";
import SoundCloudEmbed from "@/Components/Embed/SoundCloudEmded";
import MixCloudEmbed from "@/Components/Embed/MixCloudEmbed";
import SpotifyEmbed from "@/Components/Embed/SpotifyEmbed";
import TidalEmbed from "@/Components/Embed/TidalEmbed";
import TwitchEmbed from "@/Components/Embed/TwitchEmbed";
import AppleMusicEmbed from "@/Components/Embed/AppleMusicEmbed";
import WavlakeEmbed from "@/Components/Embed/WavlakeEmbed";
import LinkPreview from "@/Components/Embed/LinkPreview";
import NostrLink from "@/Components/Embed/NostrLink";
import MagnetLink from "@/Components/Embed/MagnetLink";
import { ReactNode } from "react";
interface HypeTextProps {
link: string;
children?: ReactNode | Array<ReactNode> | null;
depth?: number;
showLinkPreview?: boolean;
}
export default function HyperText({ link, depth, showLinkPreview, children }: HypeTextProps) {
const a = link;
try {
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const isSpotifyLink = SpotifyRegex.test(a);
const isTwitchLink = TwitchRegex.test(a);
const isAppleMusicLink = AppleMusicRegex.test(a);
const isNostrNestsLink = NostrNestsRegex.test(a);
const isWavlakeLink = WavlakeRegex.test(a);
if (youtubeId) {
return (
<iframe
className="-mx-4 md:mx-0 w-max my-2"
src={`https://www.youtube.com/embed/${youtubeId}`}
title="YouTube video player"
key={youtubeId}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
/>
);
} else if (tidalId) {
return <TidalEmbed link={a} />;
} else if (soundcloundId) {
return <SoundCloudEmbed link={a} />;
} else if (mixcloudId) {
return <MixCloudEmbed link={a} />;
} else if (isSpotifyLink) {
return <SpotifyEmbed link={a} />;
} else if (isTwitchLink) {
return <TwitchEmbed link={a} />;
} else if (isAppleMusicLink) {
return <AppleMusicEmbed link={a} />;
} else if (isNostrNestsLink) {
return (
<>
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{children ?? a}
</a>
{/*<NostrNestsEmbed link={a} />,*/}
</>
);
} else if (isWavlakeLink) {
return <WavlakeEmbed link={a} />;
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
return <NostrLink link={a} depth={depth} />;
} else if (url.protocol === "magnet:") {
const parsed = magnetURIDecode(a);
if (parsed) {
return <MagnetLink magnet={parsed} />;
}
} else if (showLinkPreview ?? true) {
return <LinkPreview url={a} />;
}
} catch {
// Ignore the error.
}
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{children ?? a}
</a>
);
}

View File

@ -0,0 +1,95 @@
.note-invoice {
border: 1px solid var(--gray-superdark);
border-radius: 16px;
padding: 26px 26px 20px 26px;
flex-direction: column;
align-items: flex-start;
position: relative;
background: var(--invoice-gradient);
}
.note-invoice.error {
padding: 8px 12px !important;
color: #aaa;
background: transparent;
}
.note-invoice.expired {
background: var(--expired-invoice-gradient);
color: var(--font-secondary-color);
cursor: not-allowed;
}
.note-invoice.paid {
background: var(--paid-invoice-gradient);
cursor: not-allowed;
}
.invoice-header h4 {
margin: 0;
padding: 0;
font-weight: 400;
font-size: 16px;
line-height: 19px;
margin-bottom: 10px;
}
.note-invoice .invoice-amount {
margin-bottom: 16px;
}
.note-invoice .invoice-body {
color: var(--font-secondary-color);
width: 100%;
font-size: 16px;
line-height: 19px;
}
.note-invoice .invoice-body p {
margin-bottom: 16px;
}
.note-invoice .invoice-body button {
width: 100%;
height: 44px;
font-weight: 600;
font-size: 19px;
line-height: 23px;
}
.note-invoice.expired .invoice-body button {
color: var(--font-secondary-color);
}
.note-invoice .invoice-body .paid {
width: 100%;
height: 44px;
font-weight: 600;
font-size: 19px;
line-height: 23px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--success);
color: white;
border-radius: 16px;
}
.note-invoice .invoice-amount {
font-weight: 400;
font-size: 37px;
line-height: 45px;
}
.note-invoice .invoice-amount .sats {
color: var(--font-secondary-color);
text-transform: uppercase;
font-size: 21px;
}
.zap-circle {
position: absolute;
top: 26px;
right: 20px;
color: var(--font-color);
}

View File

@ -0,0 +1,90 @@
import "./Invoice.css";
import { useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useMemo } from "react";
import { decodeInvoice } from "@snort/shared";
import classNames from "classnames";
import SendSats from "@/Components/SendSats/SendSats";
import Icon from "@/Components/Icons/Icon";
import { useWallet } from "@/Wallet";
import messages from "../messages";
export interface InvoiceProps {
invoice: string;
}
export default function Invoice(props: InvoiceProps) {
const invoice = props.invoice;
const { formatMessage } = useIntl();
const [showInvoice, setShowInvoice] = useState(false);
const walletState = useWallet();
const wallet = walletState.wallet;
const info = useMemo(() => decodeInvoice(invoice), [invoice]);
const [isPaid, setIsPaid] = useState(false);
const isExpired = info?.expired;
const amount = info?.amount ?? 0;
const description = info?.description;
function header() {
return (
<>
<h4>
<FormattedMessage {...messages.Invoice} />
</h4>
<Icon name="zapCircle" className="zap-circle" />
<SendSats
title={formatMessage(messages.PayInvoice)}
invoice={invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
/>
</>
);
}
async function payInvoice(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation();
if (wallet?.isReady) {
try {
await wallet.payInvoice(invoice);
setIsPaid(true);
} catch (error) {
setShowInvoice(true);
}
} else {
setShowInvoice(true);
}
}
return (
<>
<div className={classNames("note-invoice flex", { expired: isExpired, paid: isPaid })}>
<div className="invoice-header">{header()}</div>
<p className="invoice-amount">
{amount > 0 && (
<>
{(amount / 1_000).toLocaleString()} <span className="sats">sat{amount === 1_000 ? "" : "s"}</span>
</>
)}
</p>
<div className="invoice-body">
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">
<FormattedMessage defaultMessage="Paid" id="u/vOPu" />
</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>
{isExpired ? <FormattedMessage {...messages.Expired} /> : <FormattedMessage {...messages.Pay} />}
</button>
)}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,64 @@
.link-preview-container {
border-radius: 12px;
background: #151515;
overflow: hidden;
}
.link-preview-container:hover {
cursor: pointer;
}
.link-preview-container > a {
text-decoration: none;
}
.link-preview-title {
padding: 0 10px 10px 10px;
line-height: 21px;
}
.link-preview-title > h1 {
padding: 0;
font-size: 16px;
font-weight: 700;
line-height: initial;
margin: 0.5em 0;
}
.link-preview-container:hover .link-preview-title > h1 {
color: var(--highlight);
}
.link-preview-title > small {
color: var(--font-secondary-color);
font-size: 14px;
}
.link-preview-title > small.host {
font-size: 12px;
}
.link-preview-image {
margin: 0 0 15px 0 !important;
border-radius: 0 !important;
background-image: var(--img-url);
min-height: 220px;
max-height: 500px;
background-size: cover;
background-position: center;
}
.light .link-preview-container {
background: #fff;
border: 1px solid var(--border-color);
}
.light .link-preview-container:hover {
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 3px;
}
@media (min-width: 1025px) {
.link-preview-image {
min-height: 342px;
}
}

View File

@ -0,0 +1,89 @@
import "./LinkPreview.css";
import { CSSProperties, useEffect, useState } from "react";
import Spinner from "@/Components/Icons/Spinner";
import SnortApi, { LinkPreviewData } from "@/External/SnortApi";
import useImgProxy from "@/Hooks/useImgProxy";
import { MediaElement } from "@/Components/Embed/MediaElement";
async function fetchUrlPreviewInfo(url: string) {
const api = new SnortApi();
try {
return await api.linkPreview(url.endsWith(")") ? url.slice(0, -1) : url);
} catch (e) {
console.warn(`Failed to load link preview`, url);
}
}
const LinkPreview = ({ url }: { url: string }) => {
const [preview, setPreview] = useState<LinkPreviewData | null>();
const { proxy } = useImgProxy();
useEffect(() => {
(async () => {
const data = await fetchUrlPreviewInfo(url);
if (data) {
const type = data.og_tags?.find(a => a[0].toLowerCase() === "og:type");
const canPreviewType = type?.[1].startsWith("image") || type?.[1].startsWith("video") || false;
if (canPreviewType || data.image) {
setPreview(data);
return;
}
}
setPreview(null);
})();
}, [url]);
if (preview === null)
return (
<a href={url} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{url}
</a>
);
function previewElement() {
const type = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:type")?.[1];
if (type?.startsWith("video")) {
const urlTags = ["og:video:secure_url", "og:video:url", "og:video"];
const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1];
const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:video:type")?.[1] ?? "video/mp4";
if (link) {
return <MediaElement url={link} mime={videoType} />;
}
}
if (type?.startsWith("image")) {
const urlTags = ["og:image:secure_url", "og:image:url", "og:image"];
const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1];
const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:image:type")?.[1] ?? "image/png";
if (link) {
return <MediaElement url={link} mime={videoType} />;
}
}
if (preview?.image) {
const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : "";
const style = { "--img-url": backgroundImage } as CSSProperties;
return <div className="link-preview-image" style={style}></div>;
}
return null;
}
return (
<div className="link-preview-container">
{preview && (
<a href={url} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{previewElement()}
<div className="link-preview-title">
<h1>{preview?.title}</h1>
{preview?.description && <small>{preview.description.slice(0, 160)}</small>}
<br />
<small className="host">{new URL(url).host}</small>
</div>
</a>
)}
{!preview && <Spinner className="items-center" />}
</div>
);
};
export default LinkPreview;

View File

@ -0,0 +1,22 @@
import { FormattedMessage } from "react-intl";
import { Magnet } from "@/Utils";
interface MagnetLinkProps {
magnet: Magnet;
}
const MagnetLink = ({ magnet }: MagnetLinkProps) => {
return (
<div className="note-invoice">
<h4>
<FormattedMessage defaultMessage="Magnet Link" id="Gcn9NQ" />
</h4>
<a href={magnet.raw} rel="noreferrer">
{magnet.dn ?? magnet.infoHash}
</a>
</div>
);
};
export default MagnetLink;

View File

@ -0,0 +1,122 @@
import { ProxyImg } from "@/Components/ProxyImg";
import useImgProxy from "@/Hooks/useImgProxy";
import { IMeta } from "@snort/system";
import React, { CSSProperties, useEffect, useMemo, useRef } from "react";
import classNames from "classnames";
import { useInView } from "react-intersection-observer";
interface MediaElementProps {
mime: string;
url: string;
meta?: IMeta;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
interface AudioElementProps {
url: string;
}
interface VideoElementProps {
url: string;
meta?: IMeta;
}
interface ImageElementProps {
url: string;
meta?: IMeta;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
const AudioElement = ({ url }: AudioElementProps) => {
return <audio key={url} src={url} controls />;
};
const ImageElement = ({ url, meta, onMediaClick }: ImageElementProps) => {
const imageRef = useRef<HTMLImageElement | null>(null);
const style = useMemo(() => {
const style = {} as CSSProperties;
if (meta?.height && meta.width && imageRef.current) {
const scale = imageRef.current.offsetWidth / meta.width;
style.height = `${Math.min(document.body.clientHeight * 0.8, meta.height * scale)}px`;
}
return style;
}, [imageRef.current, meta]);
return (
<div
className={classNames("flex items-center -mx-4 md:mx-0 my-2", {
"md:h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
})}>
<ProxyImg
key={url}
src={url}
sha256={meta?.sha256}
onClick={onMediaClick}
className={classNames("max-h-[80vh] w-full h-full object-contain object-center", {
"md:max-h-[510px]": !meta && !CONFIG.media.preferLargeMedia,
})}
style={style}
ref={imageRef}
/>
</div>
);
};
const VideoElement = ({ url }: VideoElementProps) => {
const { proxy } = useImgProxy();
const videoRef = useRef<HTMLVideoElement | null>(null);
const { ref: videoContainerRef, inView } = useInView({ threshold: 0.33 });
const isMobile = window.innerWidth < 768;
useEffect(() => {
if (isMobile || !videoRef.current) {
return;
}
if (inView) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}, [inView]);
return (
<div
ref={videoContainerRef}
className={classNames("flex justify-center items-center -mx-4 md:mx-0 my-2", {
"md:h-[510px]": !CONFIG.media.preferLargeMedia,
})}>
<video
ref={videoRef}
loop={true}
muted={!isMobile}
src={url}
controls
poster={proxy(url)}
className={classNames("max-h-[80vh]", { "md:max-h-[510px]": !CONFIG.media.preferLargeMedia })}
onClick={e => e.stopPropagation()}
/>
</div>
);
};
export function MediaElement(props: MediaElementProps) {
if (props.mime.startsWith("image/")) {
return <ImageElement url={props.url} meta={props.meta} onMediaClick={props.onMediaClick} />;
} else if (props.mime.startsWith("audio/")) {
return <AudioElement url={props.url} />;
} else if (props.mime.startsWith("video/")) {
return <VideoElement url={props.url} />;
} else {
return (
<a
key={props.url}
href={props.url}
onClick={e => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext">
{props.url}
</a>
);
}
}

View File

@ -0,0 +1,35 @@
import { NostrLink, NostrPrefix } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import DisplayName from "@/Components/User/DisplayName";
import { ProfileCard } from "@/Components/User/ProfileCard";
import { ProfileLink } from "@/Components/User/ProfileLink";
import { useCallback, useRef, useState } from "react";
export default function Mention({ link }: { link: NostrLink }) {
const profile = useUserProfile(link.id);
const [isHovering, setIsHovering] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(true), 100); // Adjust timeout as needed
}, []);
const handleMouseLeave = useCallback(() => {
hoverTimeoutRef.current && clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => setIsHovering(false), 300); // Adjust timeout as needed
}, []);
if (link.type !== NostrPrefix.Profile && link.type !== NostrPrefix.PublicKey) return;
return (
<span className="highlight" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<ProfileLink pubkey={link.id} link={link} user={profile} onClick={e => e.stopPropagation()}>
@<DisplayName user={profile} pubkey={link.id} />
</ProfileLink>
{isHovering && <ProfileCard pubkey={link.id} user={profile} show={true} />}
</span>
);
}

View File

@ -0,0 +1,23 @@
import { MixCloudRegex } from "@/Utils/Const";
import useLogin from "@/Hooks/useLogin";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const theme = useLogin(s => s.appData.item.preferences.theme);
const lightParams = theme === "light" ? "light=1" : "light=0";
return (
<>
<br />
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
/>
</>
);
};
export default MixCloudEmbed;

View File

@ -0,0 +1,34 @@
import { Link } from "react-router-dom";
import { NostrPrefix, tryParseNostrLink } from "@snort/system";
import Mention from "@/Components/Embed/Mention";
import NoteQuote from "@/Components/Event/NoteQuote";
export default function NostrLink({ link, depth }: { link: string; depth?: number }) {
const nav = tryParseNostrLink(link);
if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) {
if (nav.id.startsWith("npub")) {
// eslint-disable-next-line no-debugger
debugger;
}
return <Mention link={nav} />;
} else if (nav?.type === NostrPrefix.Note || nav?.type === NostrPrefix.Event || nav?.type === NostrPrefix.Address) {
if ((depth ?? 0) > 0) {
const evLink = nav.encode();
return (
<Link to={`/${evLink}`} onClick={e => e.stopPropagation()} state={{ from: location.pathname }}>
#{evLink.substring(0, 12)}
</Link>
);
} else {
return <NoteQuote link={nav} depth={depth} />;
}
} else {
return (
<a href={link} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{link}
</a>
);
}
}

View File

@ -0,0 +1,5 @@
const NostrNestsEmbed = ({ link }: { link: string }) => (
<iframe src={link} allow="microphone" width="480" height="680" style={{ maxHeight: 680 }}></iframe>
);
export default NostrNestsEmbed;

View File

@ -0,0 +1,85 @@
import { NostrEvent } from "@snort/system";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { LNURL } from "@snort/shared";
import { dedupe, findTag, hexToBech32, getDisplayName } from "@/Utils";
import FollowListBase from "@/Components/User/FollowListBase";
import AsyncButton from "@/Components/Button/AsyncButton";
import { useWallet } from "@/Wallet";
import { Toastore } from "@/Components/Toaster/Toaster";
import { UserCache } from "@/Cache";
import useLogin from "@/Hooks/useLogin";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { WalletInvoiceState } from "@/Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
const wallet = useWallet();
const login = useLogin();
const { publisher } = useEventPublisher();
const ids = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1]));
async function zapAll() {
for (const pk of ids) {
try {
const profile = await UserCache.get(pk);
const amtSend = login.appData.item.preferences.defaultZapAmount;
const lnurl = profile?.lud16 || profile?.lud06;
if (lnurl) {
const svc = new LNURL(lnurl);
await svc.load();
const zap = await publisher?.zap(
amtSend * 1000,
pk,
Object.keys(login.relays.item),
undefined,
`Zap from ${hexToBech32("note", ev.id)}`,
);
const invoice = await svc.getInvoice(amtSend, undefined, zap);
if (invoice.pr) {
const rsp = await wallet.wallet?.payInvoice(invoice.pr);
if (rsp?.state === WalletInvoiceState.Paid) {
Toastore.push({
element: (
<FormattedMessage
defaultMessage="Sent {n} sats to {name}"
id="Ig9/a1"
values={{
n: amtSend,
name: getDisplayName(profile, pk),
}}
/>
),
icon: "zap",
});
}
}
}
} catch (e) {
console.debug("Failed to zap", pk, e);
}
}
}
return (
<FollowListBase
pubkeys={ids}
showAbout={true}
className={className}
title={findTag(ev, "title") ?? findTag(ev, "d")}
actions={
<>
<AsyncButton className="mr5 secondary" onClick={() => zapAll()}>
<FormattedMessage
defaultMessage="Zap all {n} sats"
id="IVbtTS"
values={{
n: <FormattedNumber value={login.appData.item.preferences.defaultZapAmount * ids.length} />,
}}
/>
</AsyncButton>
</>
}
/>
);
}

View File

@ -0,0 +1,12 @@
const SoundCloudEmbed = ({ link }: { link: string }) => {
return (
<iframe
width="100%"
height="166"
scrolling="no"
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${link}`}></iframe>
);
};
export default SoundCloudEmbed;

View File

@ -0,0 +1,16 @@
const SpotifyEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
return (
<iframe
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="352"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"></iframe>
);
};
export default SpotifyEmbed;

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { TidalRegex } from "@/Utils/Const";
// Re-use dom parser across instances of TidalEmbed
const domParser = new DOMParser();
async function oembedLookup(link: string) {
// Regex + re-construct to handle both tidal.com/type/id and tidal.com/browse/type/id links.
const regexResult = TidalRegex.exec(link);
if (!regexResult) {
return Promise.reject("Not a TIDAL link.");
}
const [, productType, productId] = regexResult;
const oembedApi = `https://oembed.tidal.com/?url=https://tidal.com/browse/${productType}/${productId}`;
const apiResponse = await fetch(oembedApi);
const json = await apiResponse.json();
const doc = domParser.parseFromString(json.html, "text/html");
const iframe = doc.querySelector("iframe");
if (!iframe) {
return Promise.reject("No iframe delivered.");
}
return {
source: iframe.getAttribute("src"),
height: json.height,
};
}
const TidalEmbed = ({ link }: { link: string }) => {
const [source, setSource] = useState<string>();
const [height, setHeight] = useState<number>();
const extraStyles = link.includes("video") ? { aspectRatio: "16 / 9" } : { height };
useEffect(() => {
oembedLookup(link)
.then(data => {
setSource(data.source || undefined);
setHeight(data.height);
})
.catch(console.error);
}, [link]);
if (!source)
return (
<a href={link} target="_blank" rel="noreferrer" onClick={e => e.stopPropagation()} className="ext">
{link}
</a>
);
return <iframe src={source} style={extraStyles} width="100%" title="TIDAL Embed" frameBorder={0} />;
};
export default TidalEmbed;

View File

@ -0,0 +1,8 @@
const TwitchEmbed = ({ link }: { link: string }) => {
const channel = link.split("/").slice(-1);
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
return <iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true}></iframe>;
};
export default TwitchEmbed;

View File

@ -0,0 +1,15 @@
const WavlakeEmbed = ({ link }: { link: string }) => {
const convertedUrl = link.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
return (
<iframe
style={{ borderRadius: 12 }}
src={convertedUrl}
width="100%"
height="380"
frameBorder="0"
loading="lazy"></iframe>
);
};
export default WavlakeEmbed;

View File

@ -0,0 +1,20 @@
.zapstr {
}
.zapstr > img {
margin: 0 10px 0 0;
}
.zapstr audio {
margin: 0;
height: 2em;
}
.zapstr .pfp .avatar {
width: 35px;
height: 35px;
}
.zapstr .pfp .subheader {
text-transform: capitalize;
}

View File

@ -0,0 +1,39 @@
import "./ZapstrEmbed.css";
import { Link } from "react-router-dom";
import { NostrEvent, NostrLink } from "@snort/system";
import { ProxyImg } from "@/Components/ProxyImg";
import ProfileImage from "@/Components/User/ProfileImage";
import { FormattedMessage } from "react-intl";
export default function ZapstrEmbed({ ev }: { ev: NostrEvent }) {
const media = ev.tags.find(a => a[0] === "media");
const cover = ev.tags.find(a => a[0] === "cover");
const subject = ev.tags.find(a => a[0] === "subject");
const refPersons = ev.tags.filter(a => a[0] === "p");
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
return (
<>
<div className="flex zapstr mb10 card">
<ProxyImg src={cover?.[1] ?? ""} size={100} />
<div className="flex flex-col">
<div>
<h3>{subject?.[1] ?? ""}</h3>
</div>
<audio src={media?.[1] ?? ""} controls={true} />
<div>
{refPersons.map(a => (
<ProfileImage key={a[1]} pubkey={a[1]} subHeader={<>{a[2] ?? ""}</>} className="" defaultNip=" " />
))}
</div>
</div>
</div>
<Link to={`https://zapstr.live/?track=${link}`} target="_blank">
<button>
<FormattedMessage defaultMessage="Open on Zapstr" id="Lu5/Bj" />
</button>
</Link>
</>
);
}

View File

@ -0,0 +1,39 @@
import React from "react";
interface ErrorBoundaryState {
hasError: boolean;
errorMessage?: string;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, errorMessage: error.message };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
// Render any custom fallback UI with the error message
return (
<div className="p-2">
<h1>Something went wrong.</h1>
<p>Error: {this.state.errorMessage}</p>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,25 @@
import { OfflineError } from "@snort/shared";
import { Offline } from "./Offline";
import classNames from "classnames";
import Icon from "@/Components/Icons/Icon";
export function ErrorOrOffline({
error,
onRetry,
className,
}: {
error: Error;
onRetry?: () => void | Promise<void>;
className?: string;
}) {
if (error instanceof OfflineError) {
return <Offline onRetry={onRetry} className={className} />;
} else {
return (
<div className={classNames("flex flex-row items-center px-4 py-3 gap-2", className)}>
<Icon name="alert-circle" size={24} />
<b>{error.message}</b>
</div>
);
}
}

View File

@ -0,0 +1,122 @@
.note-creator {
border: 1px solid transparent;
border-radius: 12px;
box-shadow: 0px 0px 6px 1px rgba(182, 108, 156, 0.3);
background:
linear-gradient(var(--gray-superdark), var(--gray-superdark)) padding-box,
linear-gradient(90deg, #ef9644, #fd7c49, #ff5e58, #ff3b70, #ff088e, #eb00b1, #c31ed5, #7b41f6) border-box;
}
.note-creator-modal .modal-body > div {
display: flex;
flex-direction: column;
gap: 16px;
}
.note-creator-modal .note.card {
padding: 0;
border: none;
min-height: unset;
}
.note-creator-modal .note.card.note-quote {
border: 1px solid var(--gray);
padding: 8px 12px;
}
.note-creator-modal h4 {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.21px;
text-transform: uppercase;
color: var(--gray-light);
margin: 0;
}
.note-creator-relay {
background-color: var(--gray-dark);
border-radius: 12px;
}
.note-creator textarea {
border: none;
outline: none;
resize: none;
padding: 0;
border-radius: 0;
margin: 8px 12px;
background-color: var(--gray-superdark);
min-height: 100px;
width: stretch;
width: -webkit-fill-available;
width: -moz-available;
max-height: 210px;
}
.note-creator textarea::placeholder {
color: var(--font-secondary-color);
font-size: var(--font-size);
line-height: 24px;
}
.note-creator.poll textarea {
min-height: 120px;
}
.note-creator .error {
position: absolute;
left: 16px;
bottom: 12px;
color: var(--error);
margin-right: 12px;
font-size: 16px;
}
.note-creator-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
}
.note-creator-icon.pfp .avatar {
width: 32px;
height: 32px;
}
.light .note-creator textarea {
background-color: var(--gray-superdark);
}
.light .note-creator {
box-shadow: 0px 0px 6px 1px rgba(182, 108, 156, 0.3);
background:
linear-gradient(var(--gray-superdark), var(--gray-superdark)) padding-box,
linear-gradient(90deg, #ef9644, #fd7c49, #ff5e58, #ff3b70, #ff088e, #eb00b1, #c31ed5, #7b41f6) border-box;
}
.note-creator-modal .rti--container {
background-color: unset !important;
box-shadow: unset !important;
border: 2px solid var(--border-color) !important;
border-radius: 12px !important;
padding: 4px 8px !important;
}
.note-creator-modal .rti--tag {
color: black !important;
padding: 4px 10px !important;
border-radius: 12px !important;
display: unset !important;
}
.note-creator-modal .rti--input {
width: 100% !important;
border: unset !important;
}
.note-creator-modal .rti--tag button {
padding: 0 0 0 var(--rti-s);
}

View File

@ -0,0 +1,691 @@
import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { EventBuilder, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
import classNames from "classnames";
import { TagsInput } from "react-tag-input-component";
import Icon from "@/Components/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { appendDedupe, openFile, trackEvent } from "@/Utils";
import Textarea from "@/Components/Textarea/Textarea";
import Modal from "@/Components/Modal/Modal";
import ProfileImage from "@/Components/User/ProfileImage";
import useFileUpload from "@/Utils/Upload";
import Note from "@/Components/Event/Note";
import { ClipboardEventHandler, DragEvent, useEffect } from "react";
import useLogin from "@/Hooks/useLogin";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
import { ZapTarget } from "@/Utils/Zapper";
import { useNoteCreator } from "@/State/NoteCreator";
import FileUploadProgress from "../FileUpload";
import { ToggleSwitch } from "@/Components/Icons/Toggle";
import { sendEventToRelays } from "@/Components/Event/Create/util";
import { TrendingHashTagsLine } from "@/Components/Event/Create/TrendingHashTagsLine";
import { Toastore } from "@/Components/Toaster/Toaster";
import { OkResponseRow } from "./OkResponseRow";
import CloseButton from "@/Components/Button/CloseButton";
import { GetPowWorker } from "@/Utils/wasm";
export function NoteCreator() {
const { formatMessage } = useIntl();
const uploader = useFileUpload();
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.item.preferences.pow }));
const { system, publisher: pub } = useEventPublisher();
const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub;
const note = useNoteCreator();
const relays = login.relays;
useEffect(() => {
const draft = localStorage.getItem("msgDraft");
if (draft) {
note.update(n => (n.note = draft));
}
}, []);
async function buildNote() {
try {
note.update(v => (v.error = ""));
if (note && publisher) {
let extraTags: Array<Array<string>> | undefined;
if (note.zapSplits) {
const parsedSplits = [] as Array<ZapTarget>;
for (const s of note.zapSplits) {
if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) {
const link = tryParseNostrLink(s.value);
if (link) {
parsedSplits.push({ ...s, value: link.id });
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Failed to parse zap split: {input}",
id: "sZQzjQ",
},
{
input: s.value,
},
),
);
}
} else if (s.value.includes("@")) {
const [name, domain] = s.value.split("@");
const pubkey = await fetchNip05Pubkey(name, domain);
if (pubkey) {
parsedSplits.push({ ...s, value: pubkey });
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Failed to parse zap split: {input}",
id: "sZQzjQ",
},
{
input: s.value,
},
),
);
}
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Invalid zap split: {input}",
id: "8Y6bZQ",
},
{
input: s.value,
},
),
);
}
}
extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]);
}
if (note.sensitive) {
extraTags ??= [];
extraTags.push(["content-warning", note.sensitive]);
}
const kind = note.pollOptions ? EventKind.Polls : EventKind.TextNote;
if (note.pollOptions) {
extraTags ??= [];
extraTags.push(...note.pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
}
if (note.hashTags.length > 0) {
extraTags ??= [];
extraTags.push(...note.hashTags.map(a => ["t", a.toLowerCase()]));
}
// add quote repost
if (note.quote) {
if (!note.note.endsWith("\n")) {
note.note += "\n";
}
const link = NostrLink.fromEvent(note.quote);
note.note += `nostr:${link.encode(CONFIG.eventLinkPrefix)}`;
const quoteTag = link.toEventTag();
if (quoteTag) {
extraTags ??= [];
if (quoteTag[0] === "e") {
quoteTag[0] = "q"; // how to 'q' tag replacable events?
}
extraTags.push(quoteTag);
}
}
const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t));
note.extraTags?.forEach(t => eb.tag(t));
eb.kind(kind);
return eb;
};
const ev = note.replyTo
? await publisher.reply(note.replyTo, note.note, hk)
: await publisher.note(note.note, hk);
return ev;
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
}
});
}
}
async function sendNote() {
const ev = await buildNote();
if (ev) {
let props: Record<string, boolean> | undefined = undefined;
if (ev.tags.find(a => a[0] === "content-warning")) {
props ??= {};
props["content-warning"] = true;
}
if (ev.tags.find(a => a[0] === "poll_option")) {
props ??= {};
props["poll"] = true;
}
if (ev.tags.find(a => a[0] === "zap")) {
props ??= {};
props["zap-split"] = true;
}
trackEvent("PostNote", props);
const events = (note.otherEvents ?? []).concat(ev);
events.map(a =>
sendEventToRelays(system, a, note.selectedCustomRelays, r => {
if (CONFIG.noteCreatorToast) {
r.forEach(rr => {
Toastore.push({
element: c => <OkResponseRow rsp={rr} close={c} />,
expire: unixNow() + (rr.ok ? 5 : 55555),
});
});
}
}),
);
note.update(n => n.reset());
localStorage.removeItem("msgDraft");
}
}
async function attachFile() {
try {
const file = await openFile();
if (file) {
uploadFile(file);
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
}
});
}
}
async function uploadFile(file: File) {
try {
if (file) {
const rx = await uploader.upload(file, file.name);
note.update(v => {
if (rx.header) {
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode(
CONFIG.eventLinkPrefix,
)}`;
v.note = `${v.note ? `${v.note}\n` : ""}${link}`;
v.otherEvents = [...(v.otherEvents ?? []), rx.header];
} else if (rx.url) {
v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
if (rx.metadata) {
v.extraTags ??= [];
const imeta = ["imeta", `url ${rx.url}`];
if (rx.metadata.blurhash) {
imeta.push(`blurhash ${rx.metadata.blurhash}`);
}
if (rx.metadata.width && rx.metadata.height) {
imeta.push(`dim ${rx.metadata.width}x${rx.metadata.height}`);
}
if (rx.metadata.hash) {
imeta.push(`x ${rx.metadata.hash}`);
}
v.extraTags.push(imeta);
}
} else if (rx?.error) {
v.error = rx.error;
}
});
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
}
});
}
}
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target;
note.update(n => (n.note = value));
localStorage.setItem("msgDraft", value);
}
function cancel() {
note.update(v => {
v.show = false;
v.reset();
});
}
async function onSubmit(ev: React.MouseEvent) {
ev.stopPropagation();
await sendNote();
}
async function loadPreview() {
if (note.preview) {
note.update(v => (v.preview = undefined));
} else if (publisher) {
const tmpNote = await buildNote();
trackEvent("PostNotePreview");
note.update(v => (v.preview = tmpNote));
}
}
function getPreviewNote() {
if (note.preview) {
return (
<Note
data={note.preview as TaggedNostrEvent}
related={[]}
options={{
showContextMenu: false,
showFooter: false,
canClick: false,
showTime: false,
}}
/>
);
}
}
function renderPollOptions() {
if (note.pollOptions) {
return (
<>
<h4>
<FormattedMessage defaultMessage="Poll Options" id="vhlWFg" />
</h4>
{note.pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}>
<div>
<FormattedMessage defaultMessage="Option: {n}" id="mfe8RW" values={{ n: i + 1 }} />
</div>
<div>
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
{i > 1 && <CloseButton className="ml5" onClick={() => removePollOption(i)} />}
</div>
</div>
))}
<button onClick={() => note.update(v => (v.pollOptions = [...(note.pollOptions ?? []), ""]))}>
<Icon name="plus" size={14} />
</button>
</>
);
}
}
function changePollOption(i: number, v: string) {
if (note.pollOptions) {
const copy = [...note.pollOptions];
copy[i] = v;
note.update(v => (v.pollOptions = copy));
}
}
function removePollOption(i: number) {
if (note.pollOptions) {
const copy = [...note.pollOptions];
copy.splice(i, 1);
note.update(v => (v.pollOptions = copy));
}
}
function renderRelayCustomisation() {
return (
<div className="flex flex-col g8">
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
.map((r, i, a) => (
<div className="p flex justify-between note-creator-relay" key={r}>
<div>{r}</div>
<div>
<input
type="checkbox"
checked={!note.selectedCustomRelays || note.selectedCustomRelays.includes(r)}
onChange={e => {
note.update(
v =>
(v.selectedCustomRelays =
// set false if all relays selected
e.target.checked &&
note.selectedCustomRelays &&
note.selectedCustomRelays.length == a.length - 1
? undefined
: // otherwise return selectedCustomRelays with target relay added / removed
a.filter(el =>
el === r
? e.target.checked
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
)),
);
}}
/>
</div>
</div>
))}
</div>
);
}
/*function listAccounts() {
return LoginStore.getSessions().map(a => (
<MenuItem
onClick={ev => {
ev.stopPropagation = true;
LoginStore.switchAccount(a);
}}>
<ProfileImage pubkey={a} link={""} />
</MenuItem>
));
}*/
function noteCreatorAdvanced() {
return (
<>
<div>
<h4>
<FormattedMessage defaultMessage="Custom Relays" id="EcZF24" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" id="th5lxp" />
</p>
{renderRelayCustomisation()}
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Zap Splits" id="5CB6zB" />
</h4>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." id="LwYmVi" />
<div className="flex flex-col g8">
{[...(note.zapSplits ?? [])].map((v: ZapTarget, i, arr) => (
<div className="flex items-center g8" key={`${v.name}-${v.value}`}>
<div className="flex flex-col flex-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" id="8Rkoyb" />
</h4>
<input
type="text"
value={v.value}
onChange={e =>
note.update(
v => (v.zapSplits = arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))),
)
}
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address", id: "WvGmZT" })}
/>
</div>
<div className="flex flex-col flex-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" id="zCb8fX" />
</h4>
<input
type="number"
min={0}
value={v.weight}
onChange={e =>
note.update(
v =>
(v.zapSplits = arr.map((vv, ii) =>
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
)),
)
}
/>
</div>
<div className="flex flex-col s g4">
<div>&nbsp;</div>
<Icon
name="close"
onClick={() => note.update(v => (v.zapSplits = (v.zapSplits ?? []).filter((_v, ii) => ii !== i)))}
/>
</div>
</div>
))}
<button
type="button"
onClick={() =>
note.update(v => (v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
}>
<FormattedMessage defaultMessage="Add" id="2/2yg+" />
</button>
</div>
<span className="warning">
<FormattedMessage
defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured"
id="6bgpn+"
/>
</span>
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Sensitive Content" id="bQdA2k" />
</h4>
<FormattedMessage
defaultMessage="Users must accept the content warning to show the content of your note."
id="UUPFlt"
/>
<input
className="w-max"
type="text"
value={note.sensitive}
onChange={e => note.update(v => (v.sensitive = e.target.value))}
maxLength={50}
minLength={1}
placeholder={formatMessage({
defaultMessage: "Reason",
id: "AkCxS/",
})}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" id="gXgY3+" />
</span>
</div>
</>
);
}
function noteCreatorFooter() {
return (
<div className="flex justify-between">
<div className="flex items-center g8">
<ProfileImage
pubkey={login.publicKey ?? ""}
className="note-creator-icon"
link=""
showUsername={false}
showFollowDistance={false}
showProfileCard={false}
/>
{note.pollOptions === undefined && !note.replyTo && (
<AsyncIcon
iconName="list"
iconSize={24}
onClick={() => note.update(v => (v.pollOptions = ["A", "B"]))}
className={classNames("note-creator-icon", { active: note.pollOptions !== undefined })}
/>
)}
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
<AsyncIcon
iconName="settings-04"
iconSize={24}
onClick={() => note.update(v => (v.advanced = !v.advanced))}
className={classNames("note-creator-icon", { active: note.advanced })}
/>
<span className="sm:inline hidden">
<FormattedMessage defaultMessage="Preview" id="TJo5E6" />
</span>
<ToggleSwitch
onClick={() => loadPreview()}
size={40}
className={classNames({ active: Boolean(note.preview) })}
/>
</div>
<div className="flex g8">
<button className="secondary" onClick={cancel}>
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
</button>
<AsyncButton onClick={onSubmit} className="primary">
{note.replyTo ? (
<FormattedMessage defaultMessage="Reply" id="9HU8vw" />
) : (
<FormattedMessage defaultMessage="Send" id="9WRlF4" />
)}
</AsyncButton>
</div>
</div>
);
}
const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => {
if (evt.clipboardData) {
const clipboardItems = evt.clipboardData.items;
const items: DataTransferItem[] = Array.from(clipboardItems).filter(function (item: DataTransferItem) {
// Filter the image items only
return /^image\//.test(item.type);
});
if (items.length === 0) {
return;
}
const item = items[0];
const blob = item.getAsFile();
if (blob) {
uploadFile(blob);
}
}
};
const handleDragOver = (event: DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
};
const handleDragLeave = (event: DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
};
const handleDrop = (event: DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
const droppedFiles = Array.from(event.dataTransfer.files);
droppedFiles.forEach(async file => {
await uploadFile(file);
});
};
function noteCreatorForm() {
return (
<>
{note.replyTo && (
<>
<h4>
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
</h4>
<Note
data={note.replyTo}
related={[]}
options={{
showFooter: false,
showContextMenu: false,
showProfileCard: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
}}
/>
</>
)}
{note.quote && (
<>
<h4>
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</h4>
<Note
data={note.quote}
related={[]}
options={{
showFooter: false,
showContextMenu: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
}}
/>
</>
)}
{note.preview && getPreviewNote()}
{!note.preview && (
<>
<div onPaste={handlePaste} className={classNames("note-creator", { poll: Boolean(note.pollOptions) })}>
<Textarea
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
autoFocus
className={classNames("textarea", { "textarea--focused": note.active })}
onChange={c => onChange(c)}
value={note.note}
onFocus={() => note.update(v => (v.active = true))}
onKeyDown={e => {
if (e.key === "Enter" && e.metaKey) {
sendNote().catch(console.warn);
}
}}
/>
{renderPollOptions()}
</div>
<div className="flex flex-col g4">
<TagsInput
value={note.hashTags}
onChange={e => note.update(s => (s.hashTags = e))}
placeHolder={formatMessage({
defaultMessage: "Add up to 4 hashtags",
id: "AIgmDy",
})}
separators={["Enter", ","]}
/>
{note.hashTags.length > 4 && (
<small className="warning">
<FormattedMessage defaultMessage="Try to use less than 5 hashtags to stay on topic 🙏" id="d8gpCh" />
</small>
)}
<TrendingHashTagsLine onClick={t => note.update(s => (s.hashTags = appendDedupe(s.hashTags, [t])))} />
</div>
</>
)}
{uploader.progress.length > 0 && <FileUploadProgress progress={uploader.progress} />}
{noteCreatorFooter()}
{note.error && <span className="error">{note.error}</span>}
{note.advanced && noteCreatorAdvanced()}
</>
);
}
function reset() {
note.update(v => {
v.show = false;
});
}
if (!note.show) return null;
return (
<Modal
id="note-creator"
bodyClassName="modal-body flex flex-col gap-4"
className="note-creator-modal"
onClose={reset}>
{noteCreatorForm()}
</Modal>
);
}

View File

@ -0,0 +1,84 @@
import { useRef, useMemo } from "react";
import { useLocation } from "react-router-dom";
import classNames from "classnames";
import { isFormElement } from "@/Utils";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Components/Icons/Icon";
import { useNoteCreator } from "@/State/NoteCreator";
import { NoteCreator } from "./NoteCreator";
import { FormattedMessage } from "react-intl";
export const NoteCreatorButton = ({
className,
alwaysShow,
showText,
}: {
className?: string;
alwaysShow?: boolean;
showText?: boolean;
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const location = useLocation();
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
const { show, replyTo, update } = useNoteCreator(v => ({ show: v.show, replyTo: v.replyTo, update: v.update }));
useKeyboardShortcut("n", event => {
// if event happened in a form element, do nothing, otherwise focus on search input
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
if (buttonRef.current) {
buttonRef.current.click();
}
}
});
const shouldHideNoteCreator = useMemo(() => {
if (alwaysShow) {
return false;
}
const isReply = replyTo && show;
const hideOn = [
"/settings",
"/messages",
"/new",
"/login",
"/donate",
"/e",
"/nevent",
"/note1",
"/naddr",
"/subscribe",
];
return (readonly || hideOn.some(a => location.pathname.startsWith(a))) && !isReply;
}, [location, readonly]);
return (
<>
{!shouldHideNoteCreator && (
<button
ref={buttonRef}
className={classNames(
"aspect-square flex flex-row items-center primary rounded-full",
{ "xl:aspect-auto": showText },
className,
)}
onClick={() =>
update(v => {
v.replyTo = undefined;
v.show = true;
})
}>
<Icon name="plus" size={16} />
{showText && (
<span className="ml-2 hidden xl:inline">
<FormattedMessage defaultMessage="New Note" id="2mcwT8" />
</span>
)}
</button>
)}
<NoteCreator key="global-note-creator" />
</>
);
};

View File

@ -0,0 +1,64 @@
import AsyncButton from "@/Components/Button/AsyncButton";
import IconButton from "@/Components/Button/IconButton";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Components/Icons/Icon";
import { removeRelay } from "@/Utils/Login";
import { saveRelays } from "@/Pages/settings/Relays";
import { getRelayName } from "@/Utils";
import { unwrap, sanitizeRelayUrl } from "@snort/shared";
import { OkResponse } from "@snort/system";
import { useState } from "react";
import { useIntl } from "react-intl";
export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) {
const [r, setResult] = useState(rsp);
const { formatMessage } = useIntl();
const { publisher, system } = useEventPublisher();
const login = useLogin();
async function removeRelayFromResult(r: OkResponse) {
if (publisher) {
removeRelay(login, unwrap(sanitizeRelayUrl(r.relay)));
await saveRelays(system, publisher, login.relays.item);
}
close();
}
async function retryPublish(r: OkResponse) {
const rsp = await system.WriteOnceToRelay(unwrap(sanitizeRelayUrl(r.relay)), r.event);
setResult(rsp);
}
return (
<div className="flex items-center g16">
<div className="flex flex-col grow g4">
<b>{getRelayName(r.relay)}</b>
{r.message && <small>{r.message}</small>}
</div>
{!r.ok && (
<div className="flex g8">
<AsyncButton
onClick={() => retryPublish(r)}
className="p4 br-compact flex items-center secondary"
title={formatMessage({
defaultMessage: "Retry publishing",
id: "9kSari",
})}>
<Icon name="refresh-ccw-01" />
</AsyncButton>
<AsyncButton
onClick={() => removeRelayFromResult(r)}
className="p4 br-compact flex items-center secondary"
title={formatMessage({
defaultMessage: "Remove from my relays",
id: "UJTWqI",
})}>
<Icon name="trash-01" className="trash-icon" />
</AsyncButton>
</div>
)}
<IconButton icon={{ name: "x" }} onClick={close} />
</div>
);
}

View File

@ -0,0 +1,36 @@
import { useLocale } from "@/IntlProvider";
import NostrBandApi from "@/External/NostrBand";
import { FormattedMessage } from "react-intl";
import useCachedFetch from "@/Hooks/useCachedFetch";
import { ErrorOrOffline } from "@/Components/ErrorOrOffline";
export function TrendingHashTagsLine(props: { onClick: (tag: string) => void }) {
const { lang } = useLocale();
const api = new NostrBandApi();
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
const { data: hashtags, isLoading, error } = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags);
if (error && !hashtags) return <ErrorOrOffline error={error} className="p" />;
if (isLoading || hashtags.length === 0) return null;
return (
<div className="flex flex-col g4">
<small>
<FormattedMessage defaultMessage="Popular Hashtags" id="ddd3JX" />
</small>
<div className="flex g4 flex-wrap">
{hashtags.slice(0, 5).map(a => (
<span
key={a.hashtag}
className="px-2 py-1 bg-dark rounded-full pointer nowrap"
onClick={() => props.onClick(a.hashtag)}>
#{a.hashtag}
</span>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { NostrEvent, OkResponse, SystemInterface } from "@snort/system";
import { removeUndefined } from "@snort/shared";
export async function sendEventToRelays(
system: SystemInterface,
ev: NostrEvent,
customRelays?: Array<string>,
setResults?: (x: Array<OkResponse>) => void,
) {
if (customRelays) {
system.HandleEvent({ ...ev, relays: [] });
return removeUndefined(
await Promise.all(
customRelays.map(async r => {
try {
return await system.WriteOnceToRelay(r, ev);
} catch (e) {
console.error(e);
}
}),
),
);
} else {
const responses: OkResponse[] = await system.BroadcastEvent(ev);
setResults?.(responses);
return responses;
}
}

View File

@ -0,0 +1,15 @@
import Progress from "@/Components/Progress/Progress";
import { UploadProgress } from "@/Utils/Upload";
export default function FileUploadProgress({ progress }: { progress: Array<UploadProgress> }) {
return (
<div className="flex flex-col g8">
{progress.map(p => (
<div key={p.id} className="flex flex-col g2" id={p.id}>
{p.file.name}
<Progress value={p.progress} status={p.stage} />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,23 @@
import messages from "../messages";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const [show, setShow] = useState(false);
return show ? (
children
) : (
<div className="card note hidden-note">
<div className="header">
<p>
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
</p>
<button type="button" onClick={() => setShow(true)}>
<FormattedMessage {...messages.Show} />
</button>
</div>
</div>
);
};
export default HiddenNote;

View File

@ -0,0 +1,67 @@
.long-form-note p {
font-family: Georgia;
line-height: 1.7;
}
.long-form-note hr {
border: 0;
height: 1px;
background-color: var(--gray);
margin: 5px 0px;
}
.long-form-note .reading {
border: 1px dashed var(--highlight);
}
.long-form-note .header-image {
height: 360px;
background: var(--img);
background-position: center;
background-size: cover;
}
.long-form-note h1 {
font-size: 32px;
font-weight: 700;
line-height: 40px; /* 125% */
margin: 0;
}
.long-form-note small {
font-weight: 400;
line-height: 24px; /* 150% */
}
.long-form-note img:not(.custom-emoji),
.long-form-note video,
.long-form-note iframe,
.long-form-note audio {
width: 100%;
display: block;
}
.long-form-note iframe,
.long-form-note video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.long-form-note .footer {
display: flex;
}
.long-form-note .footer .footer-reactions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: auto;
gap: 48px;
}
@media (min-width: 720px) {
.long-form-note .footer .footer-reactions {
margin-left: 0;
}
}

View File

@ -0,0 +1,168 @@
import "./LongFormText.css";
import React, { CSSProperties, useCallback, useRef, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { findTag } from "@/Utils";
import Text from "@/Components/Text/Text";
import { Markdown } from "./Markdown";
import useImgProxy from "@/Hooks/useImgProxy";
import ProfilePreview from "@/Components/User/ProfilePreview";
import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
import classNames from "classnames";
interface LongFormTextProps {
ev: TaggedNostrEvent;
isPreview: boolean;
related: ReadonlyArray<TaggedNostrEvent>;
onClick?: () => void;
truncate?: boolean;
}
const TEXT_TRUNCATE_LENGTH = 400;
export function LongFormText(props: LongFormTextProps) {
const title = findTag(props.ev, "title");
const summary = findTag(props.ev, "summary");
const image = findTag(props.ev, "image");
const { proxy } = useImgProxy();
const [reading, setReading] = useState(false);
const [showMore, setShowMore] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), props.related);
function previewText() {
return (
<Text
id={props.ev.id}
content={props.ev.content}
tags={props.ev.tags}
creator={props.ev.pubkey}
truncate={props.isPreview ? 250 : undefined}
disableLinkPreview={props.isPreview}
/>
);
}
function readTime() {
const wpm = 225;
const words = props.ev.content.trim().split(/\s+/).length;
return {
words,
wpm,
mins: Math.ceil(words / wpm),
};
}
const readAsync = async (text: string) => {
return await new Promise<void>(resolve => {
const ut = new SpeechSynthesisUtterance(text);
ut.onend = () => {
resolve();
};
window.speechSynthesis.speak(ut);
});
};
const readArticle = useCallback(async () => {
if (ref.current && !reading) {
setReading(true);
const paragraphs = ref.current.querySelectorAll("p,h1,h2,h3,h4,h5,h6");
for (const p of paragraphs) {
if (p.textContent) {
p.classList.add("reading");
await readAsync(p.textContent);
p.classList.remove("reading");
}
}
setReading(false);
}
}, [ref, reading]);
const stopReading = () => {
setReading(false);
if (ref.current) {
const paragraphs = ref.current.querySelectorAll("p,h1,h2,h3,h4,h5,h6");
paragraphs.forEach(a => a.classList.remove("reading"));
window.speechSynthesis.cancel();
}
};
const ToggleShowMore = () => (
<a
className="highlight cursor-pointer"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const shouldTruncate = props.truncate && props.ev.content.length > TEXT_TRUNCATE_LENGTH;
const content = shouldTruncate && !showMore ? props.ev.content.slice(0, TEXT_TRUNCATE_LENGTH) : props.ev.content;
function fullText() {
return (
<>
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
<hr />
<div className="flex g8">
<div>
<FormattedMessage
defaultMessage="{n} mins to read"
id="zm6qS1"
values={{
n: <FormattedNumber value={readTime().mins} />,
}}
/>
</div>
<div></div>
{!reading && (
<div className="pointer" onClick={() => readArticle()}>
<FormattedMessage defaultMessage="Listen to this article" id="nihgfo" />
</div>
)}
{reading && (
<div className="pointer" onClick={() => stopReading()}>
<FormattedMessage defaultMessage="Stop listening" id="U1aPPi" />
</div>
)}
</div>
<hr />
{shouldTruncate && showMore && <ToggleShowMore />}
<Markdown content={content} tags={props.ev.tags} ref={ref} />
{shouldTruncate && !showMore && <ToggleShowMore />}
<hr />
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
</>
);
}
return (
<div className={classNames("long-form-note flex flex-col g16 p break-words")}>
<ProfilePreview
pubkey={props.ev.pubkey}
actions={
<>
<NoteTime from={props.ev.created_at * 1000} />
</>
}
options={{
about: false,
}}
/>
<h1>{title}</h1>
<small>{summary}</small>
{image && <div className="header-image" style={{ "--img": `url(${proxy(image)})` } as CSSProperties} />}
{props.isPreview ? previewText() : fullText()}
</div>
);
}

View File

@ -0,0 +1,44 @@
.markdown a {
color: var(--highlight);
}
.markdown blockquote {
margin: 0;
color: var(--font-secondary-color);
border-left: 2px solid var(--font-secondary-color);
padding-left: 12px;
}
.markdown hr {
border: 0;
height: 1px;
background-image: var(--gray-gradient);
margin: 20px;
}
.markdown img:not(.custom-emoji),
.markdown video,
.markdown iframe,
.markdown audio {
width: 100%;
display: block;
}
.markdown iframe,
.markdown video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.markdown ul,
.markdown ol {
padding-inline-start: 20px;
}
.markdown ul {
list-style: circle;
}
.markdown ol {
list-style: decimal;
}

View File

@ -0,0 +1,136 @@
import "./Markdown.css";
import { ReactNode, forwardRef, useMemo } from "react";
import { transformText } from "@snort/system";
import { marked, Token } from "marked";
import { Link } from "react-router-dom";
import markedFootnote, { Footnotes, Footnote, FootnoteRef } from "marked-footnote";
import { ProxyImg } from "@/Components/ProxyImg";
import NostrLink from "@/Components/Embed/NostrLink";
interface MarkdownProps {
content: string;
tags?: Array<Array<string>>;
}
function renderToken(t: Token | Footnotes | Footnote | FootnoteRef, tags: Array<Array<string>>): ReactNode {
try {
switch (t.type) {
case "paragraph": {
return <p>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</p>;
}
case "image": {
return <ProxyImg src={t.href} />;
}
case "heading": {
switch (t.depth) {
case 1:
return <h1>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h1>;
case 2:
return <h2>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h2>;
case 3:
return <h3>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h3>;
case 4:
return <h4>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h4>;
case 5:
return <h5>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h5>;
case 6:
return <h6>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h6>;
}
throw new Error("Invalid heading");
}
case "codespan": {
return <code>{t.raw}</code>;
}
case "code": {
return <pre>{t.raw}</pre>;
}
case "br": {
return <br />;
}
case "hr": {
return <hr />;
}
case "blockquote": {
return <blockquote>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</blockquote>;
}
case "link": {
return (
<Link to={t.href as string} className="ext" target="_blank">
{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}
</Link>
);
}
case "list": {
if (t.ordered) {
return <ol>{(t.items as Token[]).map(a => renderToken(a, tags))}</ol>;
} else {
return <ul>{(t.items as Token[]).map(a => renderToken(a, tags))}</ul>;
}
}
case "list_item": {
return <li>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</li>;
}
case "em": {
return <em>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</em>;
}
case "del": {
return <s>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</s>;
}
case "footnoteRef": {
return (
<sup>
<Link to={`#fn-${t.label}`} className="super">
[{t.label}]
</Link>
</sup>
);
}
case "footnotes":
case "footnote": {
return;
}
default: {
if ("tokens" in t) {
return (t.tokens as Array<Token>).map(a => renderToken(a, tags));
}
return transformText(t.raw, tags).map(v => {
switch (v.type) {
case "link": {
if (v.content.startsWith("nostr:")) {
return <NostrLink link={v.content} />;
} else {
return v.content;
}
}
case "mention": {
return <NostrLink link={v.content} />;
}
default: {
return v.content;
}
}
});
}
}
} catch (e) {
console.error(e);
}
}
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
const parsed = useMemo(() => {
return marked.use(markedFootnote()).lexer(props.content);
}, [props.content, props.tags]);
return (
<div className="markdown" ref={ref}>
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a, props.tags ?? []))}
</div>
);
});
Markdown.displayName = "Markdown";
export { Markdown };

View File

@ -0,0 +1,51 @@
import { FormattedMessage } from "react-intl";
import { NostrEvent, NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { findTag } from "@/Utils";
import PageSpinner from "@/Components/PageSpinner";
import Reveal from "@/Components/Event/Reveal";
import { MediaElement } from "@/Components/Embed/MediaElement";
export default function NostrFileHeader({ link }: { link: NostrLink }) {
const ev = useEventFeed(link);
if (!ev.data) return <PageSpinner />;
return <NostrFileElement ev={ev.data} />;
}
export function NostrFileElement({ ev }: { ev: NostrEvent }) {
// assume image or embed which can be rendered by the hypertext kind
// todo: make use of hash
// todo: use magnet or other links if present
const u = findTag(ev, "url");
const x = findTag(ev, "x");
const m = findTag(ev, "m");
const blurHash = findTag(ev, "blurhash");
const magnet = findTag(ev, "magnet");
if (u && m) {
return (
<Reveal
message={
<FormattedMessage defaultMessage="Click to load content from {link}" id="lsNFM1" values={{ link: u }} />
}>
<MediaElement
mime={m}
url={u}
meta={{
sha256: x,
magnet: magnet,
blurHash: blurHash,
}}
/>
</Reveal>
);
} else {
return (
<b className="error">
<FormattedMessage defaultMessage="Unknown file header: {name}" id="PamNxw" values={{ name: ev.content }} />
</b>
);
}
}

View File

@ -0,0 +1,176 @@
.note > .header .reply {
font-size: 13px;
color: var(--font-secondary-color);
}
.note > .header .reply a {
color: var(--highlight);
}
.note > .header .reply a:hover {
text-decoration-color: var(--highlight);
}
.note .header .info {
font-size: var(--font-size);
margin-left: 4px;
white-space: nowrap;
color: var(--font-secondary-color);
display: flex;
align-items: center;
gap: 8px;
}
.note .header .info .saved {
margin-right: 12px;
font-weight: 600;
font-size: 10px;
line-height: 12px;
letter-spacing: 0.11em;
text-transform: uppercase;
display: flex;
align-items: center;
}
.note .header .info .saved svg {
margin-right: 8px;
}
.note .header .pinned {
font-size: var(--font-size-small);
color: var(--font-secondary-color);
font-weight: 500;
line-height: 22px;
display: flex;
flex-direction: row;
align-items: center;
}
.note .header .pinned svg {
margin-right: 8px;
}
.note-quote {
border: 1px solid var(--gray-superdark);
border-radius: 12px;
padding: 8px 16px 16px 16px;
margin-top: 16px;
}
.note .footer .footer-reactions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: auto;
gap: 48px;
}
@media (min-width: 720px) {
.note .footer .footer-reactions {
margin-left: 0;
}
}
.note > .header img:hover,
.note > .header .name > .reply:hover {
cursor: pointer;
}
.note > .note-creator {
margin-top: 12px;
margin-left: 56px;
}
.note .poll-body {
padding: 5px;
user-select: none;
}
.note .poll-body > div {
border: 1px solid var(--font-secondary-color);
border-radius: 5px;
margin-bottom: 3px;
position: relative;
overflow: hidden;
}
.note .poll-body > div > div {
padding: 5px 10px;
z-index: 2;
}
.note .poll-body > div:hover {
cursor: pointer;
border: 1px solid var(--highlight);
}
.note .poll-body > div > .progress {
background-color: var(--gray);
height: stretch;
height: -webkit-fill-available;
height: -moz-available;
position: absolute;
z-index: 1;
}
.reaction-pill {
display: flex;
min-width: 1rem;
align-items: center;
justify-content: center;
user-select: none;
font-feature-settings: "tnum";
gap: 5px;
}
.reaction-pill:not(.reacted):not(:hover) {
color: var(--font-secondary-color);
}
.trash-icon {
color: var(--error);
}
.note-expand .body {
max-height: 300px;
overflow-y: hidden;
}
.hidden-note .header {
display: flex;
align-items: center;
}
.card.note.hidden-note {
min-height: unset;
}
.expand-note {
padding: 0 0 16px 0;
font-weight: 400;
color: var(--highlight);
cursor: pointer;
}
.note.active {
border-left: 1px solid var(--highlight);
margin-left: -1px;
}
.note .reactions-link {
color: var(--font-secondary-color);
font-weight: 400;
font-size: 14px;
line-height: 24px;
margin-top: 4px;
font-feature-settings: "tnum";
}
.note .reactions-link:hover {
text-decoration: underline;
}
.note .body > .text > a {
color: var(--highlight);
}

View File

@ -0,0 +1,90 @@
import "./Note.css";
import { ReactNode } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { NostrFileElement } from "@/Components/Event/NostrFileHeader";
import ZapstrEmbed from "@/Components/Embed/ZapstrEmbed";
import PubkeyList from "@/Components/Embed/PubkeyList";
import { LiveEvent } from "@/Components/LiveStream/LiveEvent";
import { ZapGoal } from "@/Components/Event/ZapGoal";
import NoteReaction from "@/Components/Event/NoteReaction";
import ProfilePreview from "@/Components/User/ProfilePreview";
import { NoteInner } from "./NoteInner";
import { LongFormText } from "./LongFormText";
import ErrorBoundary from "@/Components/ErrorBoundary";
export interface NoteProps {
data: TaggedNostrEvent;
className?: string;
related: readonly TaggedNostrEvent[];
highlight?: boolean;
ignoreModeration?: boolean;
onClick?: (e: TaggedNostrEvent) => void;
depth?: number;
searchedValue?: string;
threadChains?: Map<string, Array<NostrEvent>>;
context?: ReactNode;
options?: {
isRoot?: boolean;
showHeader?: boolean;
showContextMenu?: boolean;
showProfileCard?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
showFooter?: boolean;
showReactionsLink?: boolean;
showMedia?: boolean;
canUnpin?: boolean;
canUnbookmark?: boolean;
canClick?: boolean;
showMediaSpotlight?: boolean;
longFormPreview?: boolean;
truncate?: boolean;
};
waitUntilInView?: boolean;
}
export default function Note(props: NoteProps) {
const { data: ev, className } = props;
let content;
switch (ev.kind) {
case EventKind.Repost:
content = <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
break;
case EventKind.FileHeader:
content = <NostrFileElement ev={ev} />;
break;
case EventKind.ZapstrTrack:
content = <ZapstrEmbed ev={ev} />;
break;
case EventKind.FollowSet:
case EventKind.ContactList:
content = <PubkeyList ev={ev} className={className} />;
break;
case EventKind.LiveEvent:
content = <LiveEvent ev={ev} />;
break;
case EventKind.SetMetadata:
content = <ProfilePreview actions={<></>} pubkey={ev.pubkey} />;
break;
case 9041: // Assuming 9041 is a valid EventKind
content = <ZapGoal ev={ev} />;
break;
case EventKind.LongFormTextNote:
content = (
<LongFormText
ev={ev}
related={props.related}
isPreview={props.options?.longFormPreview ?? false}
onClick={() => props.onClick?.(ev)}
truncate={props.options?.truncate}
/>
);
break;
default:
content = <NoteInner {...props} />;
}
return <ErrorBoundary>{content}</ErrorBoundary>;
}

View File

@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import Icon from "@/Components/Icons/Icon";
import { setPinned, setBookmarked } from "@/Utils/Login";
import messages from "@/Components/messages";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { ReBroadcaster } from "../ReBroadcaster";
import SnortApi from "@/External/SnortApi";
import { SubscriptionType, getCurrentSubscription } from "@/Utils/Subscription";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
}
interface NosteContextMenuProps {
ev: TaggedNostrEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
const { formatMessage } = useIntl();
const login = useLogin();
const { mute, block } = useModeration();
const { publisher, system } = useEventPublisher();
const [showBroadcast, setShowBroadcast] = useState(false);
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const isMine = ev.pubkey === login.publicKey;
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
system.BroadcastEvent(evDelete);
}
}
async function share() {
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
const url = `${window.location.protocol}//${window.location.host}/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const api = new SnortApi();
const targetLang = lang.split("-")[0].toUpperCase();
const result = await api.translate({
text: [ev.content],
target_lang: targetLang,
});
if ("translations" in result) {
if (
typeof props.onTranslated === "function" &&
result.translations.length > 0 &&
targetLang != result.translations[0].detected_source_language
) {
props.onTranslated({
text: result.translations[0].text,
fromLanguage: langNames.of(result.translations[0].detected_source_language),
confidence: 1,
} as NoteTranslation);
}
}
}
useEffect(() => {
const sub = getCurrentSubscription(login.subscriptions);
if (sub?.type === SubscriptionType.Premium && (login.appData.item.preferences.autoTranslate ?? true)) {
translate();
}
}, []);
async function copyId() {
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
const es = [...login.pinned.item, id];
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: string) {
if (publisher) {
const es = [...login.bookmarked.item, id];
const ev = await publisher.bookmarks(
es.map(a => new NostrLink(NostrPrefix.Note, a)),
"bookmark",
);
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
const handleReBroadcastButtonClick = () => {
setShowBroadcast(true);
};
function menuItems() {
return (
<>
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => props.setShowReactions(true)}>
<Icon name="heart" />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
<MenuItem onClick={() => share()}>
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!login.pinned.item.includes(ev.id) && !login.readonly && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!login.bookmarked.item.includes(ev.id) && !login.readonly && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
{!login.readonly && (
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
)}
{login.appData.item.preferences.enableReactions && !login.readonly && (
<MenuItem onClick={() => props.react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
</MenuItem>
{ev.pubkey !== login.publicKey && !login.readonly && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}>
<Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
</MenuItem>
<MenuItem onClick={() => copyEvent()}>
<Icon name="json" />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
{isMine && !login.readonly && (
<MenuItem onClick={() => deleteEvent()}>
<Icon name="trash" className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
);
}
return (
<>
<Menu
menuButton={
<div className="reaction-pill cursor-pointer">
<Icon name="dots" size={15} />
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
{showBroadcast && <ReBroadcaster ev={ev} onClose={() => setShowBroadcast(false)} />}
</>
);
}

View File

@ -0,0 +1,329 @@
import React, { forwardRef, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system";
import { normalizeReaction } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import { formatShort } from "@/Utils/Number";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { delay, findTag, getDisplayName } from "@/Utils";
import SendSats from "@/Components/SendSats/SendSats";
import { ZapsSummary } from "@/Components/Event/Zap";
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
import { useWallet } from "@/Wallet";
import useLogin from "@/Hooks/useLogin";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { useNoteCreator } from "@/State/NoteCreator";
import Icon from "@/Components/Icons/Icon";
import messages from "../messages";
let isZapperBusy = false;
const barrierZapper = async <T,>(then: () => Promise<T>): Promise<T> => {
while (isZapperBusy) {
await delay(100);
}
isZapperBusy = true;
try {
return await then();
} finally {
isZapperBusy = false;
}
};
export interface NoteFooterProps {
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
positive: TaggedNostrEvent[];
replies?: number;
ev: TaggedNostrEvent;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev, positive, reposts, zaps } = props;
const { formatMessage } = useIntl();
const {
publicKey,
preferences: prefs,
readonly,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, readonly: s.readonly }));
const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id);
const { publisher, system } = useEventPublisher();
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false);
const walletState = useWallet();
const wallet = walletState.wallet;
const canFastZap = wallet?.isReady() && !readonly;
const isMine = ev.pubkey === publicKey;
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
e => {
e.stopPropagation();
setTip(true);
},
{
captureEvent: true,
},
);
function hasReacted(emoji: string) {
return (
interactionCache.data.reacted ||
positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey)
);
}
function hasReposted() {
return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey);
}
async function react(content: string) {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
interactionCache.react();
}
}
async function repost() {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
await interactionCache.repost();
}
}
}
function getZapTarget(): Array<ZapTarget> | undefined {
if (ev.tags.some(v => v[0] === "zap")) {
return Zapper.fromEvent(ev);
}
const authorTarget = author?.lud16 || author?.lud06;
if (authorTarget) {
return [
{
type: "lnurl",
value: authorTarget,
weight: 1,
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
event: NostrLink.fromEvent(ev),
},
} as ZapTarget,
];
}
}
async function fastZap(e?: React.MouseEvent) {
if (zapping || e?.isPropagationStopped()) return;
const lnurl = getZapTarget();
if (canFastZap && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
setTip(true);
}
} finally {
setZapping(false);
}
} else {
setTip(true);
}
}
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
if (wallet) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
await barrierZapper(async () => {
const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
if (totalSent > 0) {
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
await interactionCache.zap();
}
});
}
}
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch {
// ignored
} finally {
setZapping(false);
}
});
}
}
}, [prefs.autoZap, author, zapping]);
function powIcon() {
const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (pow) {
return (
<AsyncFooterIcon
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
iconName="diamond"
value={pow}
/>
);
}
}
function tipButton() {
const targets = getZapTarget();
if (targets) {
return (
<AsyncFooterIcon
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
{...longPress()}
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
iconName={canFastZap ? "zapFast" : "zap"}
value={zapTotal}
onClick={e => fastZap(e)}
/>
);
}
return null;
}
function repostIcon() {
if (readonly) return;
return (
<Menu
menuButton={
<AsyncFooterIcon
className={hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue"}
iconName="repeat"
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
value={reposts.length}
/>
}
menuClassName="ctx-menu"
align="start">
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => repost()} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
</MenuItem>
<MenuItem
onClick={() =>
note.update(n => {
n.reset();
n.quote = ev;
n.show = true;
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</MenuItem>
</Menu>
);
}
function reactionIcon() {
if (!prefs.enableReactions) {
return null;
}
const reacted = hasReacted("+");
return (
<AsyncFooterIcon
className={reacted ? "reacted text-nostr-red" : "hover:text-nostr-red"}
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
value={positive.length}
onClick={async () => {
if (readonly) return;
await react(prefs.reactionEmoji);
}}
/>
);
}
function replyIcon() {
if (readonly) return;
return (
<AsyncFooterIcon
className={note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple"}
iconName="reply"
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
value={props.replies ?? 0}
onClick={async () => handleReplyButtonClick()}
/>
);
}
const handleReplyButtonClick = () => {
note.update(v => {
if (v.replyTo?.id !== ev.id) {
v.reset();
}
v.show = true;
v.replyTo = ev;
});
};
return (
<>
<div className="footer">
<div className="footer-reactions">
{replyIcon()}
{repostIcon()}
{reactionIcon()}
{tipButton()}
{powIcon()}
</div>
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
</div>
<ZapsSummary zaps={zaps} />
</>
);
}
const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, ref) => {
const mergedProps = {
...props,
iconSize: 18,
className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className),
};
return (
<AsyncIcon ref={ref} {...mergedProps}>
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
</AsyncIcon>
);
});
AsyncFooterIcon.displayName = "AsyncFooterIcon";

View File

@ -0,0 +1,20 @@
import "./Note.css";
import ProfileImage from "@/Components/User/ProfileImage";
interface NoteGhostProps {
className?: string;
children: React.ReactNode;
}
export default function NoteGhost(props: NoteGhostProps) {
const className = `note card ${props.className ?? ""}`;
return (
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
<div className="body">{props.children}</div>
<div className="footer"></div>
</div>
);
}

View File

@ -0,0 +1,415 @@
import { Link, useNavigate } from "react-router-dom";
import React, { ReactNode, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import classNames from "classnames";
import { EventExt, EventKind, HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { findTag, hexToBech32 } from "@/Utils";
import useModeration from "@/Hooks/useModeration";
import useLogin from "@/Hooks/useLogin";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { NoteContextMenu, NoteTranslation } from "./NoteContextMenu";
import { UserCache } from "@/Cache";
import messages from "../messages";
import { setBookmarked, setPinned } from "@/Utils/Login";
import Text from "../Text/Text";
import Reveal from "./Reveal";
import Poll from "./Poll";
import ProfileImage from "../User/ProfileImage";
import Icon from "@/Components/Icons/Icon";
import NoteTime from "./NoteTime";
import NoteFooter from "./NoteFooter";
import Reactions from "./Reactions";
import HiddenNote from "./HiddenNote";
import { NoteProps } from "./Note";
import { chainKey } from "@/Hooks/useThreadContext";
import { ProfileLink } from "@/Components/User/ProfileLink";
import DisplayName from "@/Components/User/DisplayName";
const TEXT_TRUNCATE_LENGTH = 400;
export function NoteInner(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className);
const navigate = useNavigate();
const [showReactions, setShowReactions] = useState(false);
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { reactions, reposts, deletions, zaps } = useEventReactions(NostrLink.fromEvent(ev), related);
const login = useLogin();
const { pinned, bookmarked } = useLogin();
const { publisher, system } = useEventPublisher();
const [translated, setTranslated] = useState<NoteTranslation>();
const [showTranslation, setShowTranslation] = useState(true);
const { formatMessage } = useIntl();
const [showMore, setShowMore] = useState(false);
const totalReactions = reactions.positive.length + reactions.negative.length + reposts.length + zaps.length;
const options = {
showHeader: true,
showTime: true,
showFooter: true,
canUnpin: false,
canUnbookmark: false,
showContextMenu: true,
...opt,
};
async function unpin(id: HexKey) {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
}
async function unbookmark(id: HexKey) {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
}
const ToggleShowMore = () => (
<a
className="highlight"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const innerContent = useMemo(() => {
const body = translated && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && showTranslation ? `${ev.id}-translated` : ev.id;
const shouldTruncate = opt?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
return (
<>
{shouldTruncate && showMore && <ToggleShowMore />}
<Text
id={id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
truncate={shouldTruncate && !showMore ? TEXT_TRUNCATE_LENGTH : undefined}
/>
{shouldTruncate && !showMore && <ToggleShowMore />}
</>
);
}, [
showMore,
ev,
translated,
showTranslation,
props.searchedValue,
props.depth,
options.showMedia,
props.options?.showMediaSpotlight,
opt?.truncate,
TEXT_TRUNCATE_LENGTH,
]);
const transformBody = () => {
if (deletions?.length > 0) {
return (
<b className="error">
<FormattedMessage {...messages.Deleted} />
</b>
);
}
if (!login.appData.item.showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) {
return (
<Reveal
message={
<>
<FormattedMessage
defaultMessage="The author has marked this note as a <i>sensitive topic</i>"
id="StKzTE"
values={{
i: c => <i>{c}</i>,
}}
/>
{contentWarning[1] && (
<>
&nbsp;
<FormattedMessage
defaultMessage="Reason: <i>{reason}</i>"
id="6OSOXl"
values={{
i: c => <i>{c}</i>,
reason: contentWarning[1],
}}
/>
</>
)}
. <FormattedMessage defaultMessage="Click here to load anyway" id="IoQq+a" />.{" "}
<Link to="/settings/moderation">
<i>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</i>
</Link>
</>
}>
{innerContent}
</Reveal>
);
}
}
return innerContent;
};
function goToEvent(e: React.MouseEvent, eTarget: TaggedNostrEvent) {
if (opt?.canClick === false) {
return;
}
let target = e.target as HTMLElement | null;
while (target) {
if (
target.tagName === "A" ||
target.tagName === "BUTTON" ||
target.classList.contains("reaction-pill") ||
target.classList.contains("szh-menu-container")
) {
return; // is there a better way to do this?
}
target = target.parentElement;
}
e.stopPropagation();
if (props.onClick) {
props.onClick(eTarget);
return;
}
const link = NostrLink.fromEvent(eTarget);
// detect cmd key and open in new tab
if (e.metaKey) {
window.open(`/${link.encode(CONFIG.eventLinkPrefix)}`, "_blank");
} else {
navigate(`/${link.encode(CONFIG.eventLinkPrefix)}`, {
state: eTarget,
});
}
}
function replyTag() {
const thread = EventExt.extractThread(ev);
if (thread === undefined) {
return undefined;
}
const maxMentions = 2;
const replyTo = thread?.replyTo ?? thread?.root;
const replyLink = replyTo
? NostrLink.fromTag(
[replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0),
)
: undefined;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of thread?.pubKeys ?? []) {
const u = UserCache.getFromCache(pk);
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
const shortNpub = npub.substring(0, 12);
mentions.push({
pk,
name: u?.name ?? shortNpub,
link: (
<ProfileLink pubkey={pk} user={u}>
<DisplayName pubkey={pk} user={u} />{" "}
</ProfileLink>
),
});
}
mentions.sort(a => (a.name.startsWith(NostrPrefix.PublicKey) ? 1 : -1));
const othersLength = mentions.length - maxMentions;
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
return (
<React.Fragment key={m.pk}>
{idx > 0 && ", "}
{m.link}
</React.Fragment>
);
};
const pubMentions =
mentions.length > maxMentions ? mentions?.slice(0, maxMentions).map(renderMention) : mentions?.map(renderMention);
const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : "";
const link = replyLink?.encode(CONFIG.eventLinkPrefix);
return (
<div className="reply">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions} {others}
</>
) : (
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
)}
</div>
);
}
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
if (!canRenderAsTextNote.includes(ev.kind)) {
const alt = findTag(ev, "alt");
if (alt) {
return (
<div className="note-quote">
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else {
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
</h4>
<pre>{JSON.stringify(ev, undefined, " ")}</pre>
</>
);
}
}
function translation() {
if (translated && translated.confidence > 0.5) {
return (
<>
<span
className="text-xs font-semibold text-gray-light select-none"
onClick={e => {
e.stopPropagation();
setShowTranslation(s => !s);
}}>
<FormattedMessage {...messages.TranslatedFrom} values={{ lang: translated.fromLanguage }} />
</span>
</>
);
} else if (translated) {
return (
<p className="text-xs font-semibold text-gray-light">
<FormattedMessage {...messages.TranslationFailed} />
</p>
);
}
}
function pollOptions() {
if (ev.kind !== EventKind.Polls) return;
return <Poll ev={ev} zaps={zaps} />;
}
function content() {
if (waitUntilInView && !inView) return undefined;
return (
<>
{options.showHeader && (
<div className="header flex">
<ProfileImage
pubkey={ev.pubkey}
subHeader={replyTag() ?? undefined}
link={opt?.canClick === undefined ? undefined : ""}
showProfileCard={options.showProfileCard ?? true}
showBadges={true}
/>
<div className="info">
{props.context}
{(options.showTime || options.showBookmarked) && (
<>
{options.showBookmarked && (
<div
className={`saved ${options.canUnbookmark ? "pointer" : ""}`}
onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>
)}
</div>
</div>
)}
<div className="body" onClick={e => goToEvent(e, ev, true)}>
{transformBody()}
{translation()}
{pollOptions()}
{options.showReactionsLink && (
<span className="reactions-link cursor-pointer" onClick={() => setShowReactions(true)}>
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
</span>
)}
</div>
{options.showFooter && (
<NoteFooter
ev={ev}
positive={reactions.positive}
reposts={reposts}
zaps={zaps}
replies={props.threadChains?.get(chainKey(ev))?.length}
/>
)}
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={reactions.positive}
negative={reactions.negative}
reposts={reposts}
zaps={zaps}
/>
</>
);
}
const note = (
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-color cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
{content()}
</div>
);
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{note}</HiddenNote> : note;
}

View File

@ -0,0 +1,27 @@
import { NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import Note from "@/Components/Event/Note";
import PageSpinner from "@/Components/PageSpinner";
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const ev = useEventFeed(link);
if (!ev.data)
return (
<div className="note-quote flex items-center justify-center h-[110px]">
<PageSpinner />
</div>
);
return (
<Note
data={ev.data}
related={[]}
className="note-quote"
depth={(depth ?? 0) + 1}
options={{
showFooter: false,
truncate: true,
}}
/>
);
}

View File

@ -0,0 +1,14 @@
.reaction {
display: flex;
flex-direction: column;
gap: 8px;
}
.reaction > div:nth-child(1) {
font-size: 16px;
font-weight: 600;
}
.reaction > div:nth-child(1) svg {
opacity: 0.5;
}

View File

@ -0,0 +1,99 @@
import "./NoteReaction.css";
import { Link } from "react-router-dom";
import { useMemo } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix, EventExt } from "@snort/system";
import Note from "@/Components/Event/Note";
import { eventLink, hexToBech32, getDisplayName } from "@/Utils";
import useModeration from "@/Hooks/useModeration";
import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import { useUserProfile } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
export interface NoteReactionProps {
data: TaggedNostrEvent;
root?: TaggedNostrEvent;
depth?: number;
}
export default function NoteReaction(props: NoteReactionProps) {
const { data: ev } = props;
const { isMuted } = useModeration();
const { inView, ref } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const profile = useUserProfile(inView ? ev.pubkey : "");
const root = useMemo(() => extractRoot(), [ev, props.root, inView]);
const refEvent = useMemo(() => {
if (ev) {
const eTags = ev.tags.filter(a => a[0] === "e");
if (eTags.length > 0) {
return eTags[0];
}
}
return null;
}, [ev]);
/**
* Some clients embed the reposted note in the content
*/
function extractRoot() {
if (!inView) return null;
if (ev?.kind === EventKind.Repost && ev.content.length > 0 && ev.content !== "#[0]") {
try {
const r: NostrEvent = JSON.parse(ev.content);
EventExt.fixupEvent(r);
if (!EventExt.verify(r)) {
console.debug("Event in repost is invalid");
return undefined;
}
return r as TaggedNostrEvent;
} catch (e) {
console.error("Could not load reposted content", e);
}
}
return props.root;
}
if (
ev.kind !== EventKind.Reaction &&
ev.kind !== EventKind.Repost &&
(ev.kind !== EventKind.TextNote ||
ev.tags.every((a, i) => a[1] !== refEvent?.[1] || a[3] !== "mention" || ev.content !== `#[${i}]`))
) {
return null;
}
if (!inView) {
return <div className="card reaction" ref={ref}></div>;
}
const isOpMuted = root && isMuted(root.pubkey);
const shouldNotBeRendered = isOpMuted || root?.kind !== EventKind.TextNote;
const opt = {
showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote,
showFooter: false,
truncate: true,
};
return shouldNotBeRendered ? null : (
<div className="card reaction">
<div className="flex g4">
<Icon name="repeat" size={18} />
<FormattedMessage
defaultMessage="{name} reposted"
id="+xliwN"
values={{
name: getDisplayName(profile, ev.pubkey),
}}
/>
</div>
{root ? <Note data={root} options={opt} related={[]} depth={props.depth} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>
#{hexToBech32(NostrPrefix.Event, refEvent[1]).substring(0, 12)}
</Link>
</p>
) : null}
</div>
);
}

View File

@ -0,0 +1,74 @@
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
from: number;
fallback?: string;
}
const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
export default function NoteTime(props: NoteTimeProps) {
const { from, fallback } = props;
const [time, setTime] = useState<string | JSX.Element>(calcTime());
const absoluteTime = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from),
[from],
);
const isoDate = new Date(from).toISOString();
function calcTime() {
const fromDate = new Date(from);
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromDate.getTime()) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" id="kaaf1E" />;
} else if (timeDifference < secondsInAnHour) {
return `${Math.floor(timeDifference / secondsInAMinute)}m`;
} else if (timeDifference < secondsInADay) {
return `${Math.floor(timeDifference / secondsInAnHour)}h`;
} else {
if (fromDate.getFullYear() === currentTime.getFullYear()) {
return fromDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
} else {
return fromDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
}
}
useEffect(() => {
setTime(calcTime());
const t = setInterval(() => {
setTime(s => {
const newTime = calcTime();
if (newTime !== s) {
return newTime;
}
return s;
});
}, 60_000); // update every minute
return () => clearInterval(t);
}, [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time || fallback}
</time>
);
}

View File

@ -0,0 +1,178 @@
import { TaggedNostrEvent, ParsedZap, NostrLink } from "@snort/system";
import { LNURL } from "@snort/shared";
import { useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useWallet } from "@/Wallet";
import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import Spinner from "@/Components/Icons/Spinner";
import SendSats from "@/Components/SendSats/SendSats";
import useLogin from "@/Hooks/useLogin";
interface PollProps {
ev: TaggedNostrEvent;
zaps: Array<ParsedZap>;
}
type PollTally = "zaps" | "pubkeys";
export default function Poll(props: PollProps) {
const { formatMessage } = useIntl();
const { publisher } = useEventPublisher();
const { wallet } = useWallet();
const {
preferences: prefs,
publicKey: myPubKey,
relays,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, relays: s.relays }));
const pollerProfile = useUserProfile(props.ev.pubkey);
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");
const [voting, setVoting] = useState<number>();
const didVote = props.zaps.some(a => a.sender === myPubKey);
const isMyPoll = props.ev.pubkey === myPubKey;
const showResults = didVote || isMyPoll;
const options = props.ev.tags
.filter(a => a[0] === "poll_option")
.sort((a, b) => (Number(a[1]) > Number(b[1]) ? 1 : -1));
async function zapVote(ev: React.MouseEvent, opt: number) {
ev.stopPropagation();
if (voting || !publisher) return;
const amount = prefs.defaultZapAmount;
try {
if (amount <= 0) {
throw new Error(
formatMessage(
{
defaultMessage: "Can't vote with {amount} sats, please set a different default zap amount",
id: "NepkXH",
},
{
amount,
},
),
);
}
setVoting(opt);
const r = Object.keys(relays.item);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, NostrLink.fromEvent(props.ev), undefined, eb =>
eb.tag(["poll_option", opt.toString()]),
);
const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06;
if (!lnurl) return;
const svc = new LNURL(lnurl);
await svc.load();
if (!svc.canZap) {
throw new Error(
formatMessage({
defaultMessage: "Can't vote because LNURL service does not support zaps",
id: "fOksnD",
}),
);
}
const invoice = await svc.getInvoice(amount, undefined, zap);
if (wallet?.isReady()) {
await wallet?.payInvoice(unwrap(invoice.pr));
} else {
setInvoice(unwrap(invoice.pr));
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError(
formatMessage({
defaultMessage: "Failed to send vote",
id: "g985Wp",
}),
);
}
} finally {
setVoting(undefined);
}
}
const totalVotes = (() => {
switch (tallyBy) {
case "zaps":
return props.zaps.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0);
case "pubkeys":
return new Set(props.zaps.filter(a => a.pollOption !== undefined).map(a => unwrap(a.sender))).size;
}
})();
return (
<>
<div className="flex justify-between p">
<small>
<FormattedMessage
defaultMessage="You are voting with {amount} sats"
id="3qnJlS"
values={{
amount: formatShort(prefs.defaultZapAmount),
}}
/>
</small>
<button type="button" onClick={() => setTallyBy(s => (s !== "zaps" ? "zaps" : "pubkeys"))}>
<FormattedMessage
defaultMessage="Votes by {type}"
id="xIcAOU"
values={{
type:
tallyBy === "zaps" ? (
<FormattedMessage defaultMessage="zap" id="5BVs2e" />
) : (
<FormattedMessage defaultMessage="user" id="sUNhQE" />
),
}}
/>
</button>
</div>
<div className="poll-body">
{options.map(a => {
const opt = Number(a[1]);
const desc = a[2];
const zapsOnOption = props.zaps.filter(b => b.pollOption === opt);
const total = (() => {
switch (tallyBy) {
case "zaps":
return zapsOnOption.reduce((acc, v) => (acc += v.amount), 0);
case "pubkeys":
return new Set(zapsOnOption.map(a => unwrap(a.sender))).size;
}
})();
const weight = totalVotes === 0 ? 0 : total / totalVotes;
return (
<div key={a[1]} className="flex" onClick={e => zapVote(e, opt)}>
<div className="grow">{opt === voting ? <Spinner /> : <>{desc}</>}</div>
{showResults && (
<>
<div className="flex">
<FormattedNumber value={weight * 100} maximumFractionDigits={0} />% &nbsp;
<small>({formatShort(total)})</small>
</div>
<div style={{ width: `${weight * 100}%` }} className="progress"></div>
</>
)}
</div>
);
})}
{error && <b className="error">{error}</b>}
</div>
<SendSats show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
</>
);
}

View File

@ -0,0 +1,117 @@
.reactions-modal .modal-body {
padding: 24px 32px;
background-color: var(--gray-superdark);
border-radius: 16px;
position: relative;
min-height: 33vh;
}
.light .reactions-modal .modal-body {
background-color: var(--gray-superdark);
}
@media (max-width: 720px) {
.reactions-modal .modal-body {
padding: 12px 16px;
max-width: calc(100vw - 32px);
}
}
.reactions-modal .modal-body .close {
position: absolute;
top: 12px;
right: 16px;
color: var(--font-secondary-color);
cursor: pointer;
}
.reactions-modal .modal-body .close:hover {
color: var(--font-tertiary-color);
}
.reactions-modal .modal-body .tabs.p {
padding: 12px 0;
}
.reactions-modal .modal-body .reactions-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.reactions-modal .modal-body .tab {
background: none;
border: none;
}
.reactions-modal .modal-body .tab.active {
background: #fff;
color: #000;
}
.reactions-modal .modal-body .tab:hover {
background: rgba(255, 255, 255, 0.8);
color: #000;
border: none;
}
.reactions-modal .modal-body .reactions-header h2 {
margin: 0;
flex-grow: 1;
font-weight: 600;
font-size: 16px;
line-height: 19px;
}
.reactions-modal .modal-body .reactions-body {
overflow: scroll;
height: 40vh;
-ms-overflow-style: none;
/* for Internet Explorer, Edge */
scrollbar-width: none;
/* Firefox */
margin-top: 12px;
}
.reactions-modal .modal-body .reactions-body::-webkit-scrollbar {
display: none;
}
.reactions-item {
display: grid;
grid-template-columns: 52px auto;
align-items: center;
margin-bottom: 24px;
}
.reactions-item .reaction-icon {
display: flex;
align-items: center;
justify-content: center;
}
.reactions-item .follow-button {
margin-left: auto;
}
.reactions-item .zap-reaction-icon {
width: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.reactions-item .zap-amount {
margin-top: 10px;
font-weight: 500;
font-size: 14px;
line-height: 17px;
}
@media (max-width: 520px) {
.reactions-modal .modal-body .tab.disabled {
display: none;
}
}

View File

@ -0,0 +1,144 @@
import "./Reactions.css";
import { useState, useMemo, useEffect } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedNostrEvent, ParsedZap } from "@snort/system";
import { formatShort } from "@/Utils/Number";
import Icon from "@/Components/Icons/Icon";
import { Tab } from "@/Components/Tabs/Tabs";
import ProfileImage from "@/Components/User/ProfileImage";
import Tabs from "@/Components/Tabs/Tabs";
import Modal from "@/Components/Modal/Modal";
import messages from "../messages";
import CloseButton from "@/Components/Button/CloseButton";
interface ReactionsProps {
show: boolean;
setShow(b: boolean): void;
positive: TaggedNostrEvent[];
negative: TaggedNostrEvent[];
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
}
const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: ReactionsProps) => {
const { formatMessage } = useIntl();
const onClose = () => setShow(false);
const likes = useMemo(() => {
const sorted = [...positive];
sorted.sort((a, b) => b.created_at - a.created_at);
return sorted;
}, [positive]);
const dislikes = useMemo(() => {
const sorted = [...negative];
sorted.sort((a, b) => b.created_at - a.created_at);
return sorted;
}, [negative]);
const total = positive.length + negative.length + zaps.length + reposts.length;
const defaultTabs: Tab[] = [
{
text: formatMessage(messages.Likes, { n: likes.length }),
value: 0,
},
{
text: formatMessage(messages.Zaps, { n: zaps.length }),
value: 1,
disabled: zaps.length === 0,
},
{
text: formatMessage(messages.Reposts, { n: reposts.length }),
value: 2,
disabled: reposts.length === 0,
},
];
const tabs = defaultTabs.concat(
dislikes.length !== 0
? [
{
text: formatMessage(messages.Dislikes, { n: dislikes.length }),
value: 3,
},
]
: [],
);
const [tab, setTab] = useState(tabs[0]);
useEffect(() => {
if (!show) {
setTab(tabs[0]);
}
}, [show]);
return show ? (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
<div className="reactions-body" key={tab.value}>
{tab.value === 0 &&
likes.map(ev => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">{ev.content === "+" ? <Icon name="heart" /> : ev.content}</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
{tab.value === 1 &&
zaps.map(z => {
return (
z.sender && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<Icon name="zap" size={20} />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
overrideUsername={
z.anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined
}
/>
</div>
)
);
})}
{tab.value === 2 &&
reposts.map(ev => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Icon name="repost" size={16} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
{tab.value === 3 &&
dislikes.map(ev => {
return (
<div key={ev.id} className="reactions-item f-ellipsis">
<div className="reaction-icon">
<Icon name="dislike" />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
</div>
</Modal>
) : null;
};
export default Reactions;

View File

@ -0,0 +1,17 @@
import { WarningNotice } from "@/Components/WarningNotice/WarningNotice";
import { useState } from "react";
interface RevealProps {
message: React.ReactNode;
children: React.ReactNode;
}
export default function Reveal(props: RevealProps) {
const [reveal, setReveal] = useState(false);
if (!reveal) {
return <WarningNotice onClick={() => setReveal(true)}>{props.message}</WarningNotice>;
} else if (props.children) {
return props.children;
}
}

View File

@ -0,0 +1,89 @@
import { FormattedMessage } from "react-intl";
import { FileExtensionRegex } from "@/Utils/Const";
import Reveal from "@/Components/Event/Reveal";
import useLogin from "@/Hooks/useLogin";
import { MediaElement } from "@/Components/Embed/MediaElement";
import { Link } from "react-router-dom";
import { IMeta } from "@snort/system";
interface RevealMediaProps {
creator: string;
link: string;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
meta?: IMeta;
}
export default function RevealMedia(props: RevealMediaProps) {
const { preferences, follows, publicKey } = useLogin(s => ({
preferences: s.appData.item.preferences,
follows: s.follows.item,
publicKey: s.publicKey,
}));
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !follows.includes(props.creator);
const isMine = props.creator === publicKey;
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(props.link).hostname;
const url = new URL(props.link);
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
const type = (() => {
switch (extension) {
case "gif":
case "jpg":
case "jpeg":
case "jfif":
case "png":
case "bmp":
case "webp":
return "image";
case "wav":
case "mp3":
case "ogg":
return "audio";
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v":
case "webm":
return "video";
default:
return "unknown";
}
})();
if (hideMedia) {
return (
<Reveal
message={
<FormattedMessage
defaultMessage="You don't follow this person, click here to load media from <i>{link}</i>, or update <a><i>your preferences</i></a> to always load media from everybody."
id="HhcAVH"
values={{
i: i => <i>{i}</i>,
a: a => <Link to="/settings/preferences">{a}</Link>,
link: hostname,
}}
/>
}>
<MediaElement
mime={`${type}/${extension}`}
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
/>
</Reveal>
);
} else {
return (
<MediaElement
mime={`${type}/${extension}`}
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
/>
);
}
}

View File

@ -0,0 +1,18 @@
.show-more {
background: none;
border: none;
color: var(--highlight);
font-weight: normal;
}
.show-more:hover {
color: var(--highlight);
background: none;
border: none;
font-weight: normal;
text-decoration: underline;
}
.show-more-container {
min-height: 40px;
}

View File

@ -0,0 +1,39 @@
import "./ShowMore.css";
import { FormattedMessage } from "react-intl";
import { useInView } from "react-intersection-observer";
import { useEffect } from "react";
import classNames from "classnames";
interface ShowMoreProps {
text?: string;
className?: string;
onClick: () => void;
}
const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
return (
<div className="show-more-container">
<button className={classNames("show-more", className)} onClick={onClick}>
{text || <FormattedMessage defaultMessage="Show More" id="O8Z8t9" />}
</button>
</div>
);
};
export default ShowMore;
export function ShowMoreInView({ text, onClick, className }: ShowMoreProps) {
const { ref, inView } = useInView({ rootMargin: "2000px" });
useEffect(() => {
if (inView) {
onClick();
}
}, [inView]);
return (
<div className={classNames("show-more-container", className)} ref={ref}>
{text}
</div>
);
}

View File

@ -0,0 +1,101 @@
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-root.note {
box-shadow: none;
}
.thread-root.note > .body .text {
font-size: 18px;
line-height: 27px;
}
.thread-root.note > .footer {
padding-left: 0;
}
.thread-root.note {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.thread-note.note {
border: 0;
}
.thread-note.note .zaps-summary,
.thread-note.note .footer,
.thread-note.note .body {
margin-left: 61px;
}
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-container .show-more {
background: var(--gray-superdark);
padding-left: 76px;
width: 100%;
text-align: left;
border-radius: 0;
padding-top: 10px;
padding-bottom: 10px;
}
.subthread-container {
position: relative;
}
.subthread-container.subthread-multi .line-container:before {
content: "";
position: absolute;
left: calc(48px / 2 + 16px);
top: 48px;
border-left: 1px solid var(--border-color);
height: 100%;
z-index: 1;
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
content: "";
position: absolute;
border-left: 1px solid var(--border-color);
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: 1;
}
.subthread-container.subthread-last .line-container:before {
content: "";
position: absolute;
border-left: 1px solid var(--border-color);
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: 1;
}
.divider {
height: 1px;
background: var(--border-color);
}
.divider.divider-small {
margin-left: calc(16px + 61px);
margin-right: 16px;
}
.thread-container .collapsed,
.thread-container .show-more-container {
min-height: 48px;
}
.thread-container .hidden-note {
padding-left: 48px;
}

View File

@ -0,0 +1,354 @@
import "./Thread.css";
import { useMemo, useState, ReactNode, useContext, Fragment } from "react";
import { useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import { TaggedNostrEvent, u256, NostrPrefix, EventExt, parseNostrLink, NostrLink } from "@snort/system";
import classNames from "classnames";
import { getAllLinkReactions, getLinkReactions } from "@/Utils";
import BackButton from "@/Components/Button/BackButton";
import Note from "@/Components/Event/Note";
import NoteGhost from "@/Components/Event/NoteGhost";
import Collapsed from "@/Components/Collapsed";
import { ThreadContext, ThreadContextWrapper, chainKey } from "@/Hooks/useThreadContext";
import messages from "../messages";
interface DividerProps {
variant?: "regular" | "small";
}
const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider";
return (
<div className="divider-container">
<div className={className}></div>
</div>
);
};
interface SubthreadProps {
isLastSubthread?: boolean;
active: u256;
notes: readonly TaggedNostrEvent[];
related: readonly TaggedNostrEvent[];
chains: Map<u256, Array<TaggedNostrEvent>>;
onNavigate: (e: TaggedNostrEvent) => void;
}
const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: TaggedNostrEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.id, chains);
return (
<Fragment key={a.id}>
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
<Divider />
<Note
highlight={active === a.id}
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
data={a}
key={a.id}
related={related}
onClick={onNavigate}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
</Fragment>
);
};
return <div className="subthread">{notes.map(renderSubthread)}</div>;
};
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: TaggedNostrEvent;
isLast: boolean;
}
const ThreadNote = ({ active, note, isLast, isLastSubthread, related, chains, onNavigate }: ThreadNoteProps) => {
const { formatMessage } = useIntl();
const replies = getReplies(note.id, chains);
const activeInReplies = replies.map(r => r.id).includes(active);
const [collapsed, setCollapsed] = useState(!activeInReplies);
const hasMultipleNotes = replies.length > 1;
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
const className = classNames(
"subthread-container",
isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid",
);
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.id}
className={classNames("thread-note", { "is-last-note": isLastVisibleNote })}
data={note}
key={note.id}
related={related}
onClick={onNavigate}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
)}
</>
);
};
const TierTwo = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
return (
<>
<ThreadNote
active={active}
onNavigate={onNavigate}
note={first}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={rest.length === 0}
/>
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
return (
<ThreadNote
key={r.id}
active={active}
onNavigate={onNavigate}
note={r}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={lastReply}
/>
);
})}
</>
);
};
const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.id, chains);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
const isLast = replies.length === 0 && rest.length === 0;
return (
<>
<div
className={classNames("subthread-container", {
"subthread-multi": hasMultipleNotes,
"subthread-last": isLast,
"subthread-mid": !isLast,
})}>
<Divider variant="small" />
<Note
highlight={active === first.id}
className={classNames("thread-note", { "is-last-note": isLastSubthread && isLast })}
data={first}
key={first.id}
related={related}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
return (
<div
key={r.id}
className={classNames("subthread-container", {
"subthread-multi": !lastReply,
"subthread-last": !lastReply,
"subthread-mid": lastReply,
})}>
<Divider variant="small" />
<Note
className={classNames("thread-note", { "is-last-note": lastNote })}
highlight={active === r.id}
data={r}
key={r.id}
related={related}
onClick={onNavigate}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
);
})}
</>
);
};
export function ThreadRoute({ id }: { id?: string }) {
const params = useParams();
const resolvedId = id ?? params.id;
const link = parseNostrLink(resolvedId ?? "", NostrPrefix.Note);
return (
<ThreadContextWrapper link={link}>
<Thread />
</ThreadContextWrapper>
);
}
export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean }) {
const thread = useContext(ThreadContext);
const navigate = useNavigate();
const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0);
const { formatMessage } = useIntl();
function navigateThread(e: TaggedNostrEvent) {
thread.setCurrent(e.id);
//router.navigate(`/${NostrLink.fromEvent(e).encode()}`, { replace: true })
}
const parent = useMemo(() => {
if (thread.root) {
const currentThread = EventExt.extractThread(thread.root);
return (
currentThread?.replyTo?.value ??
currentThread?.root?.value ??
(currentThread?.root?.key === "a" && currentThread.root?.value)
);
}
}, [thread.root]);
function renderRoot(note: TaggedNostrEvent) {
const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`;
if (note) {
return (
<Note
className={className}
key={note.id}
data={note}
related={getLinkReactions(thread.reactions, NostrLink.fromEvent(note))}
options={{ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }}
onClick={navigateThread}
threadChains={thread.chains}
/>
);
} else {
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
}
}
function renderChain(from: u256): ReactNode {
if (!from || thread.chains.size === 0) {
return;
}
const replies = thread.chains.get(from);
if (replies && thread.current) {
return (
<Subthread
active={thread.current}
notes={replies}
related={getAllLinkReactions(
thread.reactions,
replies.map(a => NostrLink.fromEvent(a)),
)}
chains={thread.chains}
onNavigate={navigateThread}
/>
);
}
}
function goBack() {
if (parent) {
thread.setCurrent(parent);
} else if (props.onBack) {
props.onBack();
} else {
navigate(-1);
}
}
const parentText = formatMessage({
defaultMessage: "Parent",
id: "ADmfQT",
description: "Link to parent note in thread",
});
const debug = window.location.search.includes("debug=true");
return (
<>
{debug && (
<div className="main-content p xs">
<h1>Chains</h1>
<pre>
{JSON.stringify(
Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
undefined,
" ",
)}
</pre>
<h1>Current</h1>
<pre>{JSON.stringify(thread.current)}</pre>
<h1>Root</h1>
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
<h1>Data</h1>
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
<h1>Reactions</h1>
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
</div>
)}
{parent && (
<div className="main-content p">
<BackButton onClick={goBack} text={parentText} />
</div>
)}
<div className="main-content">
{thread.root && renderRoot(thread.root)}
{thread.root && renderChain(chainKey(thread.root))}
</div>
</>
);
}
function getReplies(from: u256, chains?: Map<u256, Array<TaggedNostrEvent>>): Array<TaggedNostrEvent> {
if (!from || !chains) {
return [];
}
const replies = chains.get(from);
return replies ? replies : [];
}

View File

@ -0,0 +1,93 @@
.zap {
min-height: unset;
}
.zap .header {
align-items: center;
flex-direction: row;
}
.zap .header .amount {
font-size: 24px;
}
@media (max-width: 520px) {
.zap .header .amount {
font-size: 16px;
}
}
.zap .header .pfp {
max-width: 72%;
}
@media (max-width: 520px) {
.zap .header .pfp {
padding: 4px;
}
}
.zap .summary {
display: flex;
flex-direction: row;
}
.zap .amount {
font-size: 18px;
}
.top-zap .amount:before {
content: "";
}
.top-zap .summary {
color: var(--font-secondary-color);
}
.zaps-summary {
display: flex;
flex-direction: row;
}
.note.thread-root .zaps-summary {
margin-left: 14px;
}
.top-zap {
font-size: 14px;
border: none;
margin: 0;
}
.top-zap .pfp {
margin-right: 0.3em;
}
.top-zap .summary .pfp .avatar-wrapper .avatar {
width: 18px;
height: 18px;
}
.top-zap .nip05 {
display: none;
}
.top-zap .summary {
display: flex;
flex-direction: row;
align-items: center;
}
.amount-number {
font-weight: 500;
}
.zap.note .body {
margin-bottom: 0;
}
@media (max-width: 420px) {
.zap .nip05 .badge {
margin: 0 0 0 0.3em;
}
}

View File

@ -0,0 +1,79 @@
import "./Zap.css";
import { useMemo } from "react";
import { ParsedZap } from "@snort/system";
import { FormattedMessage, useIntl } from "react-intl";
import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import Text from "@/Components/Text/Text";
import ProfileImage from "@/Components/User/ProfileImage";
import useLogin from "@/Hooks/useLogin";
import messages from "../messages";
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
const { amount, content, sender, valid, receiver } = zap;
const pubKey = useLogin().publicKey;
return valid && sender ? (
<div className="card">
<div className="flex justify-between">
<ProfileImage pubkey={sender} showProfileCard={true} />
{receiver !== pubKey && showZapped && <ProfileImage pubkey={unwrap(receiver)} />}
<h3>
<FormattedMessage {...messages.Sats} values={{ n: formatShort(amount ?? 0) }} />
</h3>
</div>
{(content?.length ?? 0) > 0 && sender && (
<Text id={zap.id} creator={sender} content={unwrap(content)} tags={[]} />
)}
</div>
) : null;
};
interface ZapsSummaryProps {
zaps: ParsedZap[];
}
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
const { formatMessage } = useIntl();
const sortedZaps = useMemo(() => {
const pub = [...zaps.filter(z => z.sender && z.valid)];
const priv = [...zaps.filter(z => !z.sender && z.valid)];
pub.sort((a, b) => b.amount - a.amount);
return pub.concat(priv);
}, [zaps]);
if (zaps.length === 0) {
return null;
}
const [topZap, ...restZaps] = sortedZaps;
const { sender, amount, anonZap } = topZap;
return (
<div className="zaps-summary">
{amount && (
<div className={`top-zap`}>
<div className="summary">
{sender && (
<ProfileImage
pubkey={anonZap ? "" : sender}
showFollowDistance={false}
overrideUsername={anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined}
/>
)}
{restZaps.length > 0 ? (
<FormattedMessage {...messages.Others} values={{ n: restZaps.length }} />
) : (
<FormattedMessage {...messages.Zapped} />
)}{" "}
<FormattedMessage {...messages.OthersZapped} values={{ n: restZaps.length }} />
</div>
</div>
)}
</div>
);
};
export default Zap;

View File

@ -0,0 +1,7 @@
.zap-button {
color: var(--bg-color);
background-color: var(--highlight);
padding: 4px 8px;
border-radius: 16px;
cursor: pointer;
}

View File

@ -0,0 +1,50 @@
import "./ZapButton.css";
import { useState } from "react";
import { HexKey } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import SendSats from "@/Components/SendSats/SendSats";
import Icon from "@/Components/Icons/Icon";
import { ZapTarget } from "@/Utils/Zapper";
const ZapButton = ({
pubkey,
lnurl,
children,
event,
}: {
pubkey: HexKey;
lnurl?: string;
children?: React.ReactNode;
event?: string;
}) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
const service = lnurl ?? (profile?.lud16 || profile?.lud06);
if (!service) return null;
return (
<>
<button type="button" className="flex g8" onClick={() => setZap(true)}>
<Icon name="zap-solid" />
{children}
</button>
<SendSats
targets={[
{
type: "lnurl",
value: service,
weight: 1,
name: profile?.display_name || profile?.name,
zap: { pubkey: pubkey },
} as ZapTarget,
]}
show={zap}
onClose={() => setZap(false)}
note={event}
/>
</>
);
};
export default ZapButton;

View File

@ -0,0 +1,3 @@
.zap-goal h1 {
line-height: 1em;
}

View File

@ -0,0 +1,41 @@
import "./ZapGoal.css";
import { useState } from "react";
import { NostrEvent, NostrLink } from "@snort/system";
import useZapsFeed from "@/Feed/ZapsFeed";
import { formatShort } from "@/Utils/Number";
import { findTag } from "@/Utils";
import Icon from "@/Components/Icons/Icon";
import SendSats from "../SendSats/SendSats";
import { Zapper } from "@/Utils/Zapper";
import Progress from "@/Components/Progress/Progress";
import { FormattedNumber } from "react-intl";
export function ZapGoal({ ev }: { ev: NostrEvent }) {
const [zap, setZap] = useState(false);
const zaps = useZapsFeed(NostrLink.fromEvent(ev));
const target = Number(findTag(ev, "amount"));
const amount = zaps.reduce((acc, v) => (acc += v.amount * 1000), 0);
const progress = amount / target;
return (
<div className="zap-goal card">
<div className="flex items-center justify-between">
<h2>{ev.content}</h2>
<div className="zap-button flex" onClick={() => setZap(true)}>
<Icon name="zap" size={15} />
</div>
<SendSats targets={Zapper.fromEvent(ev)} show={zap} onClose={() => setZap(false)} />
</div>
<div className="flex justify-between">
<div>
<FormattedNumber value={progress} style="percent" />
</div>
<div>
{formatShort(amount / 1000)}/{formatShort(target / 1000)}
</div>
</div>
<Progress value={progress} />
</div>
);
}

View File

@ -0,0 +1,9 @@
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import { TaggedNostrEvent } from "@snort/system";
export default function getEventMedia(event: TaggedNostrEvent) {
const parsed = transformTextCached(event.id, event.content, event.tags);
return parsed.filter(
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
);
}

View File

@ -0,0 +1,37 @@
import { NostrLink } from "@snort/system";
import { useReactions } from "@snort/system-react";
import { useArticles } from "@/Feed/ArticlesFeed";
import { orderDescending } from "@/Utils";
import Note from "../Event/Note";
import { useContext } from "react";
import { DeckContext } from "@/Pages/DeckLayout";
export default function Articles() {
const data = useArticles();
const deck = useContext(DeckContext);
const related = useReactions(
"articles:reactions",
data.data?.map(v => NostrLink.fromEvent(v)) ?? [],
undefined,
true,
);
return (
<>
{orderDescending(data.data ?? []).map(a => (
<Note
data={a}
key={a.id}
related={related.data ?? []}
options={{
longFormPreview: true,
}}
onClick={ev => {
deck?.setArticle(ev);
}}
/>
))}
</>
);
}

View File

@ -0,0 +1,47 @@
import Icon from "@/Components/Icons/Icon";
import { LoginStore } from "@/Utils/Login";
import useLogin from "@/Hooks/useLogin";
import { useCallback } from "react";
export type DisplayAs = "list" | "grid";
type DisplaySelectorProps = {
activeSelection: DisplayAs;
onSelect: (display: DisplayAs) => void;
show?: boolean;
};
export const DisplayAsSelector = ({ activeSelection, onSelect, show }: DisplaySelectorProps) => {
const state = useLogin();
const getClasses = (displayType: DisplayAs) => {
const baseClasses = "border-highlight cursor-pointer flex justify-center flex-1 p-3";
return activeSelection === displayType
? `${baseClasses} border-b border-1`
: `${baseClasses} hover:bg-nearly-bg-color text-secondary`;
};
const myOnSelect = useCallback(
(display: DisplayAs) => {
onSelect(display);
const updatedState = { ...state, feedDisplayAs: display };
LoginStore.updateSession(updatedState);
},
[onSelect, state],
);
if (show === false) return null;
return (
<div className="flex mb-px md:mb-1">
<div className={getClasses("list")} onClick={() => myOnSelect("list")}>
<span className="rotate-90">
<Icon name="deck-solid" />
</span>
</div>
<div className={getClasses("grid")} onClick={() => myOnSelect("grid")}>
<Icon name="media" />
</div>
</div>
);
};

View File

@ -0,0 +1,33 @@
import { NostrLink, NoteCollection, ReqFilter, RequestBuilder } from "@snort/system";
import { useReactions, useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react";
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
export function GenericFeed({ link }: { link: NostrLink }) {
const sub = useMemo(() => {
console.debug(link);
const sub = new RequestBuilder("generic");
sub.withOptions({ leaveOpen: true });
const reqs = JSON.parse(link.id) as Array<ReqFilter>;
reqs.forEach(a => {
const f = sub.withBareFilter(a);
link.relays?.forEach(r => f.relay(r));
});
return sub;
}, [link]);
const evs = useRequestBuilder(NoteCollection, sub);
const reactions = useReactions("generic:reactions", evs.data?.map(a => NostrLink.fromEvent(a)) ?? []);
return (
<TimelineRenderer
frags={[{ events: evs.data ?? [], refTime: 0 }]}
related={reactions.data ?? []}
latest={[]}
showLatest={() => {
//nothing
}}
/>
);
}

View File

@ -0,0 +1,37 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { MouseEvent } from "react";
import { Link } from "react-router-dom";
import Icon from "@/Components/Icons/Icon";
import getEventMedia from "@/Components/Event/getEventMedia";
import { ProxyImg } from "@/Components/ProxyImg";
const ImageGridItem = (props: { event: TaggedNostrEvent; onClick: (e: MouseEvent) => void }) => {
const { event, onClick } = props;
const media = getEventMedia(event);
if (media.length === 0) return null;
const multiple = media.length > 1;
const isVideo = media[0].mimeType?.startsWith("video/");
const noteId = NostrLink.fromEvent(event).encode(CONFIG.eventLinkPrefix);
const myOnClick = (clickEvent: MouseEvent) => {
if (onClick && window.innerWidth >= 768) {
onClick(clickEvent);
clickEvent.preventDefault();
}
};
return (
<Link to={`/${noteId}`} className="aspect-square cursor-pointer hover:opacity-80 relative" onClick={myOnClick}>
<ProxyImg src={media[0].content} alt="Note Media" className="w-full h-full object-cover" />
<div className="absolute right-2 top-2 flex flex-col gap-2">
{multiple && <Icon name="copy-solid" className="text-white opacity-80 drop-shadow-md" />}
{isVideo && <Icon name="play-square-outline" className="text-white opacity-80 drop-shadow-md" />}
</div>
</Link>
);
};
export default ImageGridItem;

View File

@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useInView } from "react-intersection-observer";
import messages from "../messages";
export default function LoadMore({
onLoadMore,
shouldLoadMore,
children,
}: {
onLoadMore: () => void;
shouldLoadMore: boolean;
children?: React.ReactNode;
}) {
const { ref, inView } = useInView({ rootMargin: "2000px" });
const [tick, setTick] = useState<number>(0);
useEffect(() => {
if (inView === true && shouldLoadMore === true) {
onLoadMore();
}
}, [inView, shouldLoadMore, tick]);
useEffect(() => {
const t = setInterval(() => {
setTick(x => (x += 1));
}, 500);
return () => clearInterval(t);
}, []);
return (
<div ref={ref} className="mb10">
{children ?? <FormattedMessage {...messages.Loading} />}
</div>
);
}

View File

@ -0,0 +1,23 @@
.root-type {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
}
.root-type > button {
background: none;
color: var(--font-color);
font-size: 16px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
border: none;
box-shadow: none;
}
.root-type > button:hover {
box-shadow: none !important;
}

View File

@ -0,0 +1,191 @@
import "./RootTabs.css";
import { useState, ReactNode, useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { FormattedMessage } from "react-intl";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Components/Icons/Icon";
import { Newest } from "@/Utils/Login";
export type RootTab =
| "following"
| "followed-by-friends"
| "conversations"
| "trending-notes"
| "trending-people"
| "suggested"
| "tags"
| "global";
export function rootTabItems(base: string, pubKey: string | undefined, tags: Newest<Array<string>>) {
const menuItems = [
{
tab: "following",
path: `${base}/notes`,
show: Boolean(pubKey),
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Following" id="cPIKU2" />
</>
),
},
{
tab: "trending-notes",
path: `${base}/trending/notes`,
show: true,
element: (
<>
<Icon name="fire" />
<FormattedMessage defaultMessage="Trending Notes" id="Ix8l+B" />
</>
),
},
{
tab: "conversations",
path: `${base}/conversations`,
show: Boolean(pubKey),
element: (
<>
<Icon name="message-chat-circle" />
<FormattedMessage defaultMessage="Conversations" id="1udzha" />
</>
),
},
{
tab: "followed-by-friends",
path: `${base}/followed-by-friends`,
show: Boolean(pubKey),
element: (
<>
<Icon name="user-v2" />
<FormattedMessage defaultMessage="Followed by friends" id="voxBKC" />
</>
),
},
{
tab: "suggested",
path: `${base}/suggested`,
show: Boolean(pubKey),
element: (
<>
<Icon name="thumbs-up" />
<FormattedMessage defaultMessage="Suggested Follows" id="C8HhVE" />
</>
),
},
{
tab: "trending-hashtags",
path: `${base}/trending/hashtags`,
show: true,
element: (
<>
<Icon name="hash" />
<FormattedMessage defaultMessage="Trending Hashtags" id="XXm7jJ" />
</>
),
},
{
tab: "global",
path: `${base}/global`,
show: true,
element: (
<>
<Icon name="globe" />
<FormattedMessage defaultMessage="Global" id="EWyQH5" />
</>
),
},
{
tab: "tags",
path: `${base}/topics`,
show: tags.item.length > 0,
element: (
<>
<Icon name="hash" />
<FormattedMessage defaultMessage="Topics" id="kc79d3" />
</>
),
},
] as Array<{
tab: RootTab;
path: string;
show: boolean;
element: ReactNode;
}>;
return menuItems;
}
export function RootTabs({ base = "/" }: { base: string }) {
const navigate = useNavigate();
const location = useLocation();
const {
publicKey: pubKey,
tags,
preferences,
} = useLogin(s => ({
publicKey: s.publicKey,
tags: s.tags,
preferences: s.appData.item.preferences,
}));
const menuItems = useMemo(() => rootTabItems(base, pubKey, tags), [base, pubKey, tags]);
const defaultTab = pubKey ? preferences.defaultRootTab ?? `${base}/notes` : `${base}/trending/notes`;
const initialPathname = location.pathname === "/" ? defaultTab : location.pathname;
const initialRootType = menuItems.find(a => a.path === initialPathname)?.tab || "following";
const [rootType, setRootType] = useState<RootTab>(initialRootType);
useEffect(() => {
const currentTab = menuItems.find(a => a.path === location.pathname)?.tab;
if (currentTab && currentTab !== rootType) {
setRootType(currentTab);
}
}, [location.pathname, menuItems, rootType]);
function currentMenuItem() {
if (location.pathname.startsWith(`${base}/t/`)) {
return (
<>
<Icon name="hash" />
{location.pathname.split("/").slice(-1)}
</>
);
}
return menuItems.find(a => a.tab === rootType)?.element;
}
return (
<div className="root-type">
<Menu
menuButton={
<button type="button">
{currentMenuItem()}
<Icon name="chevronDown" />
</button>
}
align="center"
menuClassName={() => "ctx-menu"}>
<div className="close-menu-container">
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
{menuItems
.filter(a => a.show)
.map(a => (
<MenuItem
key={a.tab}
onClick={() => {
navigate(a.path);
window.scrollTo({ top: 0, behavior: "instant" });
}}>
{a.element}
</MenuItem>
))}
</Menu>
</div>
);
}

View File

@ -0,0 +1,38 @@
.latest-notes {
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 6px 24px;
gap: 8px;
}
.latest-notes-fixed {
position: fixed;
top: 50px;
width: auto;
z-index: 42;
opacity: 0.9;
box-shadow: 0px 0px 15px rgba(78, 0, 255, 0.6);
color: white;
background: var(--highlight);
border-radius: 100px;
border: none;
}
.latest-notes .pfp:not(:last-of-type) {
margin: 0;
margin-right: -26px;
}
.latest-notes .pfp:last-of-type {
margin-right: -8px;
}
.latest-notes .pfp .avatar-wrapper .avatar {
margin: 0;
width: 32px;
height: 32px;
border: 2px solid white;
}

View File

@ -0,0 +1,115 @@
import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { useCallback, useMemo, useState } from "react";
import { TaggedNostrEvent, EventKind, socialGraphInstance } from "@snort/system";
import { dedupeByPubkey, findTag } from "@/Utils";
import useTimelineFeed, { TimelineFeed, TimelineSubject } from "@/Feed/TimelineFeed";
import useModeration from "@/Hooks/useModeration";
import { LiveStreams } from "@/Components/LiveStream/LiveStreams";
import { unixNow } from "@snort/shared";
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
import useLogin from "@/Hooks/useLogin";
export interface TimelineProps {
postsOnly: boolean;
subject: TimelineSubject;
method: "TIME_RANGE" | "LIMIT_UNTIL";
followDistance?: number;
ignoreModeration?: boolean;
window?: number;
now?: number;
loadMore?: boolean;
noSort?: boolean;
displayAs?: DisplayAs;
showDisplayAsSelector?: boolean;
}
/**
* A list of notes by "subject"
*/
const Timeline = (props: TimelineProps) => {
const login = useLogin();
const feedOptions = useMemo(() => {
return {
method: props.method,
window: props.window,
now: props.now,
};
}, [props]);
const feed: TimelineFeed = useTimelineFeed(props.subject, feedOptions);
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const { muted, isEventMuted } = useModeration();
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
const checkFollowDistance = (a: TaggedNostrEvent) => {
if (props.followDistance === undefined) {
return true;
}
const followDistance = socialGraphInstance.getFollowDistance(a.pubkey);
return followDistance === props.followDistance;
};
const a = [...nts.filter(a => a.kind !== EventKind.LiveEvent)];
props.noSort || a.sort((a, b) => b.created_at - a.created_at);
return a
?.filter(a => (props.postsOnly ? !a.tags.some(b => b[0] === "e") : true))
.filter(a => (props.ignoreModeration || !isEventMuted(a)) && checkFollowDistance(a));
},
[props.postsOnly, muted, props.ignoreModeration, props.followDistance],
);
const mainFeed = useMemo(() => {
return filterPosts(feed.main ?? []);
}, [feed, filterPosts]);
const latestFeed = useMemo(() => {
return filterPosts(feed.latest ?? []).filter(a => !mainFeed.some(b => b.id === a.id));
}, [feed, filterPosts]);
const liveStreams = useMemo(() => {
return (feed.main ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [feed]);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function onShowLatest(scrollToTop = false) {
feed.showLatest();
if (scrollToTop) {
window.scrollTo(0, 0);
}
}
return (
<>
<LiveStreams evs={liveStreams} />
<DisplayAsSelector
show={props.showDisplayAsSelector}
activeSelection={displayAs}
onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)}
/>
<TimelineRenderer
frags={[
{
events: mainFeed,
refTime: mainFeed.at(0)?.created_at ?? unixNow(),
},
]}
related={feed.related ?? []}
latest={latestAuthors}
showLatest={t => onShowLatest(t)}
displayAs={displayAs}
/>
{(props.loadMore === undefined || props.loadMore === true) && (
<div className="flex items-center px-3 py-4">
<button type="button" onClick={() => feed.loadMore()}>
<FormattedMessage defaultMessage="Load more" id="00LcfG" />
</button>
</div>
)}
</>
);
};
export default Timeline;

View File

@ -0,0 +1,139 @@
import "./Timeline.css";
import { ReactNode, useCallback, useContext, useMemo, useState, useSyncExternalStore } from "react";
import { Link } from "react-router-dom";
import { EventKind, NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import { SnortContext, useReactions } from "@snort/system-react";
import { dedupeByPubkey, findTag, orderDescending } from "@/Utils";
import useModeration from "@/Hooks/useModeration";
import { FollowsFeed } from "@/Cache";
import { LiveStreams } from "@/Components/LiveStream/LiveStreams";
import useLogin from "@/Hooks/useLogin";
import useHashtagsFeed from "@/Feed/HashtagsFeed";
import { ShowMoreInView } from "@/Components/Event/ShowMore";
import { TimelineRenderer } from "@/Components/Feed/TimelineRenderer";
import { DisplayAs, DisplayAsSelector } from "@/Components/Feed/DisplayAsSelector";
export interface TimelineFollowsProps {
postsOnly: boolean;
liveStreams?: boolean;
noteFilter?: (ev: NostrEvent) => boolean;
noteRenderer?: (ev: NostrEvent) => ReactNode;
noteOnClick?: (ev: NostrEvent) => void;
displayAs?: DisplayAs;
showDisplayAsSelector?: boolean;
}
/**
* A list of notes by "subject"
*/
const TimelineFollows = (props: TimelineFollowsProps) => {
const login = useLogin();
const displayAsInitial = props.displayAs ?? login.feedDisplayAs ?? "list";
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
const [latest, setLatest] = useState(unixNow());
const feed = useSyncExternalStore(
cb => FollowsFeed.hook(cb, "*"),
() => FollowsFeed.snapshot(),
);
const reactions = useReactions(
"follows-feed-reactions",
feed.map(a => NostrLink.fromEvent(a)),
undefined,
true,
);
const system = useContext(SnortContext);
const { muted, isEventMuted } = useModeration();
const sortedFeed = useMemo(() => orderDescending(feed), [feed]);
const oldest = useMemo(() => sortedFeed.at(-1)?.created_at, [sortedFeed]);
const postsOnly = useCallback(
(a: NostrEvent) => (props.postsOnly ? !a.tags.some(b => b[0] === "e" || b[0] === "a") : true),
[props.postsOnly],
);
const filterPosts = useCallback(
(nts: Array<TaggedNostrEvent>) => {
const a = nts.filter(a => a.kind !== EventKind.LiveEvent);
return a
?.filter(postsOnly)
.filter(a => !isEventMuted(a) && login.follows.item.includes(a.pubkey) && (props.noteFilter?.(a) ?? true));
},
[postsOnly, muted, login.follows.timestamp],
);
const mixin = useHashtagsFeed();
const mainFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at <= latest));
}, [sortedFeed, filterPosts, latest, login.follows.timestamp]);
const findHashTagContext = (a: NostrEvent) => {
const tag = a.tags.filter(a => a[0] === "t").find(a => login.tags.item.includes(a[1].toLowerCase()))?.[1];
return tag;
};
const mixinFiltered = useMemo(() => {
const mainFeedIds = new Set(mainFeed.map(a => a.id));
return (mixin.data.data ?? [])
.filter(a => !mainFeedIds.has(a.id) && postsOnly(a) && !isEventMuted(a))
.filter(a => a.tags.filter(a => a[0] === "t").length < 5)
.filter(a => !oldest || a.created_at >= oldest)
.map(
a =>
({
...a,
context: findHashTagContext(a),
}) as TaggedNostrEvent,
);
}, [mixin, mainFeed, postsOnly, isEventMuted]);
const latestFeed = useMemo(() => {
return filterPosts((sortedFeed ?? []).filter(a => a.created_at > latest));
}, [sortedFeed, latest]);
const liveStreams = useMemo(() => {
return (sortedFeed ?? []).filter(a => a.kind === EventKind.LiveEvent && findTag(a, "status") === "live");
}, [sortedFeed]);
const latestAuthors = useMemo(() => {
return dedupeByPubkey(latestFeed).map(e => e.pubkey);
}, [latestFeed]);
function onShowLatest(scrollToTop = false) {
setLatest(unixNow());
if (scrollToTop) {
window.scrollTo(0, 0);
}
}
return (
<>
{(props.liveStreams ?? true) && <LiveStreams evs={liveStreams} />}
<DisplayAsSelector
show={props.showDisplayAsSelector}
activeSelection={displayAs}
onSelect={(displayAs: DisplayAs) => setDisplayAs(displayAs)}
/>
<TimelineRenderer
frags={[{ events: orderDescending(mainFeed.concat(mixinFiltered)), refTime: latest }]}
related={reactions.data ?? []}
latest={latestAuthors}
showLatest={t => onShowLatest(t)}
noteOnClick={props.noteOnClick}
noteRenderer={props.noteRenderer}
noteContext={e => {
if (typeof e.context === "string") {
return <Link to={`/t/${e.context}`}>{`#${e.context}`}</Link>;
}
}}
displayAs={displayAs}
/>
{sortedFeed.length > 0 && (
<ShowMoreInView onClick={async () => await FollowsFeed.loadMore(system, login, oldest ?? unixNow())} />
)}
</>
);
};
export default TimelineFollows;

View File

@ -0,0 +1,51 @@
import { ReactNode, useCallback } from "react";
import { TaggedNostrEvent } from "@snort/system";
import Note from "@/Components/Event/Note";
import { findTag } from "@/Utils";
export interface TimelineFragment {
events: Array<TaggedNostrEvent>;
refTime: number;
title?: ReactNode;
}
export interface TimelineFragProps {
frag: TimelineFragment;
related: Array<TaggedNostrEvent>;
index: number;
noteRenderer?: (ev: TaggedNostrEvent) => ReactNode;
noteOnClick?: (ev: TaggedNostrEvent) => void;
noteContext?: (ev: TaggedNostrEvent) => ReactNode;
}
export function TimelineFragment(props: TimelineFragProps) {
const relatedFeed = useCallback(
(id: string) => {
return props.related.filter(a => findTag(a, "e") === id);
},
[props.related],
);
return (
<>
{props.frag.title}
{props.frag.events.map(
e =>
props.noteRenderer?.(e) ?? (
<Note
data={e}
related={relatedFeed(e.id)}
key={e.id}
depth={0}
onClick={props.noteOnClick}
context={props.noteContext?.(e)}
options={{
truncate: true,
}}
waitUntilInView={props.index > 10}
/>
),
)}
</>
);
}

View File

@ -0,0 +1,153 @@
import { useInView } from "react-intersection-observer";
import ProfileImage from "@/Components/User/ProfileImage";
import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import { TaggedNostrEvent } from "@snort/system";
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { TimelineFragment } from "@/Components/Feed/TimelineFragment";
import { DisplayAs } from "@/Components/Feed/DisplayAsSelector";
import { SpotlightThreadModal } from "@/Components/Spotlight/SpotlightThreadModal";
import ImageGridItem from "@/Components/Feed/ImageGridItem";
import ErrorBoundary from "@/Components/ErrorBoundary";
import getEventMedia from "@/Components/Event/getEventMedia";
export interface TimelineRendererProps {
frags: Array<TimelineFragment>;
related: Array<TaggedNostrEvent>;
/**
* List of pubkeys who have posted recently
*/
latest: Array<string>;
showLatest: (toTop: boolean) => void;
noteRenderer?: (ev: TaggedNostrEvent) => ReactNode;
noteOnClick?: (ev: TaggedNostrEvent) => void;
noteContext?: (ev: TaggedNostrEvent) => ReactNode;
displayAs?: DisplayAs;
}
// filter frags[0].events that have media
function Grid({ frags }: { frags: Array<TimelineFragment> }) {
const [modalEventIndex, setModalEventIndex] = useState<number | undefined>(undefined);
const allEvents = useMemo(() => {
return frags.flatMap(frag => frag.events);
}, [frags]);
const mediaEvents = useMemo(() => {
return allEvents.filter(event => getEventMedia(event).length > 0);
}, [allEvents]);
const modalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex] : undefined;
const nextModalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex + 1] : undefined;
const prevModalEvent = modalEventIndex !== undefined ? mediaEvents[modalEventIndex - 1] : undefined;
return (
<>
<div className="grid grid-cols-3 gap-px md:gap-1">
{mediaEvents.map((event, index) => (
<ImageGridItem key={event.id} event={event} onClick={() => setModalEventIndex(index)} />
))}
</div>
{modalEvent && (
<SpotlightThreadModal
key={modalEvent.id}
event={modalEvent}
onClose={() => setModalEventIndex(undefined)}
onBack={() => setModalEventIndex(undefined)}
onNext={() => setModalEventIndex(Math.min((modalEventIndex ?? 0) + 1, mediaEvents.length - 1))}
onPrev={() => setModalEventIndex(Math.max((modalEventIndex ?? 0) - 1, 0))}
/>
)}
{nextModalEvent && ( // preload next
<SpotlightThreadModal className="hidden" key={`${nextModalEvent.id}-next`} event={nextModalEvent} />
)}
{prevModalEvent && ( // preload previous
<SpotlightThreadModal className="hidden" key={`${prevModalEvent.id}-prev`} event={prevModalEvent} />
)}
</>
);
}
export function TimelineRenderer(props: TimelineRendererProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const latestNotesFixedRef = useRef<HTMLDivElement | null>(null);
const { ref, inView } = useInView();
const updateLatestNotesPosition = () => {
if (containerRef.current && latestNotesFixedRef.current) {
const parentRect = containerRef.current.getBoundingClientRect();
const childWidth = latestNotesFixedRef.current.offsetWidth;
const leftPosition = parentRect.left + (parentRect.width - childWidth) / 2;
latestNotesFixedRef.current.style.left = `${leftPosition}px`;
}
};
useEffect(() => {
updateLatestNotesPosition();
window.addEventListener("resize", updateLatestNotesPosition);
return () => {
window.removeEventListener("resize", updateLatestNotesPosition);
};
}, [inView, props.latest]);
const renderNotes = () => {
return props.frags.map((frag, index) => (
<ErrorBoundary key={frag.events[0]?.id + index}>
<TimelineFragment
frag={frag}
related={props.related}
noteRenderer={props.noteRenderer}
noteOnClick={props.noteOnClick}
noteContext={props.noteContext}
index={index}
/>
</ErrorBoundary>
));
};
return (
<div ref={containerRef}>
{props.latest.length > 0 && (
<>
<div className="card latest-notes" onClick={() => props.showLatest(false)} ref={ref}>
{props.latest.slice(0, 3).map(p => {
return <ProfileImage key={p} pubkey={p} showUsername={false} link={""} showFollowDistance={false} />;
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
id="3t3kok"
values={{ n: props.latest.length }}
/>
<Icon name="arrowUp" />
</div>
{!inView && (
<div
ref={latestNotesFixedRef}
className="card latest-notes latest-notes-fixed pointer fade-in"
onClick={() => props.showLatest(true)}>
{props.latest.slice(0, 3).map(p => {
return (
<ProfileImage
key={p}
pubkey={p}
showProfileCard={false}
showUsername={false}
link={""}
showFollowDistance={false}
/>
);
})}
<FormattedMessage
defaultMessage="{n} new {n, plural, =1 {note} other {notes}}"
id="3t3kok"
values={{ n: props.latest.length }}
/>
<Icon name="arrowUp" />
</div>
)}
</>
)}
{props.displayAs === "grid" ? <Grid frags={props.frags} /> : renderNotes()}
</div>
);
}

View File

@ -0,0 +1,31 @@
import { useCallback, useMemo } from "react";
import { TaggedNostrEvent } from "@snort/system";
import useTimelineFeed, { TimelineFeed } from "@/Feed/TimelineFeed";
import FollowListBase from "@/Components/User/FollowListBase";
import PageSpinner from "@/Components/PageSpinner";
import useModeration from "@/Hooks/useModeration";
export default function UsersFeed({ keyword, sortPopular = true }: { keyword: string; sortPopular?: boolean }) {
const feed: TimelineFeed = useTimelineFeed(
{
type: "profile_keyword",
items: [keyword + (sortPopular ? " sort:popular" : "")],
discriminator: keyword,
},
{ method: "LIMIT_UNTIL" },
);
const { muted, isEventMuted } = useModeration();
const filterPosts = useCallback(
(nts: readonly TaggedNostrEvent[]) => {
return nts.filter(a => !isEventMuted(a));
},
[muted],
);
const usersFeed = useMemo(() => filterPosts(feed.main ?? []).map(p => p.pubkey), [feed, filterPosts]);
if (!usersFeed) return <PageSpinner />;
return <FollowListBase pubkeys={usersFeed} showAbout={true} />;
}

View File

@ -0,0 +1,5 @@
const HighlightedText = ({ content }: { content: string }) => {
return <strong className="highlighted-text">{content}</strong>;
};
export default HighlightedText;

View File

@ -0,0 +1,75 @@
export default function AlbyIcon(props: { size?: number }) {
return (
<svg width={props.size ?? 400} height={props.size ?? 578} viewBox="0 0 400 578" fill="none">
<path
opacity="0.1"
d="M201.283 577.511C255.405 577.511 299.281 569.411 299.281 559.419C299.281 549.427 255.405 541.327 201.283 541.327C147.16 541.327 103.285 549.427 103.285 559.419C103.285 569.411 147.16 577.511 201.283 577.511Z"
fill="black"
/>
<path
d="M295.75 471.344C346.377 471.344 369.42 359.242 369.42 316.736C369.42 283.606 346.56 263.528 316.507 263.528C286.641 263.528 262.394 276.371 262.093 292.275C262.092 334.246 254.705 471.344 295.75 471.344Z"
fill="white"
stroke="black"
strokeWidth="15.0766"
/>
<path
d="M110.837 471.344C60.2098 471.344 37.1665 359.242 37.1665 316.736C37.1665 283.606 60.0269 263.528 90.0803 263.528C119.946 263.528 144.193 276.371 144.494 292.275C144.495 334.246 151.882 471.344 110.837 471.344Z"
fill="white"
stroke="black"
strokeWidth="15.0766"
/>
<path
d="M68.8309 303.262L68.8307 303.26C68.7764 302.741 68.8817 302.44 68.9894 302.244C69.1165 302.012 69.3578 301.736 69.7632 301.506C70.6022 301.029 71.7772 300.943 72.8713 301.582C110.474 323.624 153.847 336.26 201.001 336.26C248.164 336.26 292.34 323.379 330.185 300.953C331.272 300.308 332.445 300.388 333.287 300.862C333.694 301.091 333.937 301.366 334.066 301.599C334.175 301.796 334.282 302.098 334.229 302.618C328.375 360.632 296.907 408.595 254.611 430.672C240.642 437.965 231.035 450.634 222.598 461.761C222.447 461.961 222.296 462.16 222.146 462.358L222.144 462.36C215.287 471.406 209.081 479.507 201.496 485.476C193.912 479.507 187.705 471.406 180.848 462.36L180.847 462.358C180.697 462.16 180.546 461.961 180.395 461.761C171.958 450.634 162.352 437.965 148.382 430.672C106.247 408.68 74.8589 360.995 68.8309 303.262Z"
fill="#FFDF6F"
stroke="black"
strokeWidth="15"
/>
<path
d="M201.786 346.338C275.06 346.338 334.46 326.538 334.46 302.113C334.46 277.688 275.06 257.888 201.786 257.888C128.512 257.888 69.1118 277.688 69.1118 302.113C69.1118 326.538 128.512 346.338 201.786 346.338Z"
fill="black"
stroke="black"
strokeWidth="15.0766"
/>
<path
d="M95.2446 376.491C95.2446 376.491 160.685 398.603 202.791 398.603C244.896 398.603 310.337 376.491 310.337 376.491"
stroke="black"
strokeWidth="15.0766"
strokeLinecap="round"
/>
<path
d="M77 143C60.4315 143 47 129.569 47 113C47 96.4315 60.4315 83 77 83C93.5685 83 107 96.4315 107 113C107 129.569 93.5685 143 77 143Z"
fill="black"
/>
<path d="M72 108.5L128 164.5" stroke="black" strokeWidth="15" />
<path
d="M322 143C338.569 143 352 129.569 352 113C352 96.4315 338.569 83 322 83C305.431 83 292 96.4315 292 113C292 129.569 305.431 143 322 143Z"
fill="black"
/>
<path d="M327.5 108.5L271.5 164.5" stroke="black" strokeWidth="15" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M85.5155 292.019C69.3466 284.321 59.9364 267.036 63.0886 249.407C76.6177 173.747 133 117 200.5 117C268.163 117 324.655 174.023 338.009 249.958C341.115 267.618 331.628 284.895 315.404 292.53C280.687 308.868 241.91 318 201 318C159.665 318 120.507 308.677 85.5155 292.019Z"
fill="#FFDF6F"
/>
<path
d="M70.4715 250.728C83.5443 177.62 137.582 124.5 200.5 124.5V109.5C128.418 109.5 69.6912 169.875 55.7057 248.087L70.4715 250.728ZM200.5 124.5C263.569 124.5 317.718 177.879 330.622 251.257L345.396 248.659C331.592 170.166 272.758 109.5 200.5 109.5V124.5ZM312.21 285.744C278.472 301.621 240.783 310.5 201 310.5V325.5C243.037 325.5 282.902 316.114 318.597 299.317L312.21 285.744ZM201 310.5C160.804 310.5 122.745 301.436 88.7393 285.247L82.2918 298.791C118.269 315.918 158.526 325.5 201 325.5V310.5ZM330.622 251.257C333.112 265.416 325.531 279.476 312.21 285.744L318.597 299.317C337.725 290.315 349.117 269.82 345.396 248.659L330.622 251.257ZM55.7057 248.087C51.9285 269.211 63.2298 289.716 82.2918 298.791L88.7393 285.247C75.4633 278.927 67.9443 264.86 70.4715 250.728L55.7057 248.087Z"
fill="black"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M114.365 273.209C101.35 267.908 93.6293 254.06 98.1392 240.75C112.047 199.704 152.618 170 200.5 170C248.382 170 288.953 199.704 302.861 240.75C307.371 254.06 299.65 267.908 286.635 273.209C260.053 284.035 230.973 290 200.5 290C170.027 290 140.947 284.035 114.365 273.209Z"
fill="black"
/>
<path
d="M235 254C248.807 254 260 245.046 260 234C260 222.954 248.807 214 235 214C221.193 214 210 222.954 210 234C210 245.046 221.193 254 235 254Z"
fill="white"
/>
<path
d="M163.432 254.012C177.239 254.012 188.432 245.058 188.432 234.012C188.432 222.966 177.239 214.012 163.432 214.012C149.625 214.012 138.432 222.966 138.432 234.012C138.432 245.058 149.625 254.012 163.432 254.012Z"
fill="white"
/>
</svg>
);
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,48 @@
export default function CashuIcon(props: { size?: number }) {
return (
<svg width={props.size ?? 135} height={props.size ?? 153} viewBox="0 0 135 153">
<path
d="m 18,0 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 V 8 7 6 5 4 3 2 1 0 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 0,9 H 17 16 15 14 13 12 11 10 9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 17 16 15 14 13 12 11 10 Z M 9,18 H 8 7 6 5 4 3 2 1 0 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 H 1 2 3 4 5 6 7 8 9 V 44 43 42 41 40 39 38 37 36 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 Z M 0,53 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 H 8 7 6 V 60 59 58 57 H 5 4 3 V 56 55 54 53 H 2 1 Z m 9,55 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,18 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 81,0 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 z"
style={{
fill: "#b89563",
}}
/>
<path
d="m 36,0 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 V 8 7 6 5 4 3 2 1 0 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 45,9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 h -1 -1 -1 -1 -1 -1 -1 -1 z m 0,27 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 V 44 43 42 41 40 39 38 37 Z M 63,64 v 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,8 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,18 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 36,9 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z m 9,9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 z"
style={{
fill: "#e2d2b3",
}}
/>
<path
d="m 18,9 v 1 1 1 1 1 1 1 1 1 H 17 16 15 14 13 12 11 10 9 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 V 17 16 15 14 13 12 11 10 9 H 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 Z M 9,61 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 z"
style={{
fill: "#c5a77f",
}}
/>
<path
d="m 36,9 v 1 1 1 1 1 1 1 1 1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 1 1 1 V 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z m 9,40 v 1 1 1 1 1 1 h -1 -1 v 1 1 1 h -1 -1 -1 v 1 1 1 h -1 -1 -1 -1 v 1 1 1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 v -1 -1 -1 h -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 z"
style={{
fill: "#dbbf98",
}}
/>
<path
d="m 0,45 v 1 1 1 1 1 1 1 1 h 1 1 1 v 1 1 1 1 h 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 h 1 1 1 1 v -1 -1 -1 h 1 1 1 v -1 -1 -1 h 1 1 v -1 -1 -1 -1 -1 -1 h 1 1 1 1 1 1 v 1 1 1 1 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 v 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 V 52 51 50 49 48 47 46 45 H 95 94 93 92 91 90 89 88 87 86 85 84 83 82 81 80 79 78 77 76 75 74 73 72 71 70 69 68 67 66 65 64 63 62 61 60 59 58 57 56 55 54 53 52 51 50 49 48 47 46 45 44 43 42 41 40 39 38 37 36 35 34 33 32 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 Z m 6,4 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 1 H 17 16 15 14 V 60 59 58 57 H 13 12 11 10 V 56 55 54 53 H 9 8 7 6 v -1 -1 -1 z m 8,8 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 44,-8 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h 1 1 1 1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 -1 h -1 -1 -1 -1 v -1 -1 -1 z m 8,8 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z"
style={{
fill: "#000000",
}}
/>
<path
d="m 6,49 v 1 1 1 1 h 1 1 1 1 V 52 51 50 49 H 9 8 7 Z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,0 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 4,0 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m -4,0 h -1 -1 -1 -1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 z m 40,-8 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,0 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 -1 v 1 1 1 z m 4,0 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m 4,4 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 -1 h -1 -1 -1 z m -4,0 h -1 -1 -1 -1 v 1 1 1 1 h 1 1 1 1 v -1 -1 -1 z"
style={{
fill: "#ffffff",
}}
/>
<path
d="m 99,99 v 1 1 1 1 1 1 1 1 1 h 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 v -1 -1 -1 -1 -1 -1 -1 -1 -1 h -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 z"
style={{
fill: "#f7f8f3",
}}
/>
</svg>
);
}

View File

@ -0,0 +1,23 @@
import { MouseEventHandler } from "react";
import IconsSvg from "@/Components/Icons/icons.svg";
export interface IconProps {
name: string;
size?: number;
height?: number;
className?: string;
onClick?: MouseEventHandler<SVGSVGElement>;
}
const Icon = (props: IconProps) => {
const size = props.size || 20;
const href = `${IconsSvg}#` + props.name;
return (
<svg width={size} height={props.height ?? size} className={props.className} onClick={props.onClick}>
<use href={href} />
</svg>
);
};
export default Icon;

View File

@ -0,0 +1,11 @@
export default function NostrIcon(props: { width?: number; height?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.446 84.924" {...props}>
<path
fill="currentColor"
clipRule="evenodd"
d="m35.805 39.467c1.512-1.608 5.559-0.682 6.96-2.4-0.595-1.9-4.07-4.608-4.319-6.96-0.112-1.057 0.563-1.379 0.96-2.64 0.243-0.775 0.004-1.643 0.239-2.16 0.681-1.492 2.526-2.548 2.88-4.08-1.356-6.734 4.686-8.103 8.641-10.32 4.301 0.146 9.927-1.066 13.68 0.96 0.113 0.754-0.646 0.634-0.72 1.2 0.339 0.541 1.563 0.197 1.439 1.2-1.327 1.862-4.511-0.112-5.52 1.68 0.646 0.634 1.735 0.824 2.4 1.44v2.64c-0.708 0.172-1.486 0.274-1.921 0.72 1.552 3.67-5.669 2.291-3.359 6 1.339-0.021 4.954-0.144 6.72-1.2 2.784-1.665 2.711-6.367 5.521-8.159 0.691-0.029 1.57 0.131 1.92-0.24 1.151-2.775 3.98-5.438 8.88-5.76 2.746-0.182 8.349-1.87 10.8 0.239 1.465 1.262 0.81 3.268 2.16 4.561 0.988 0.451 2.105 0.774 2.16 2.16 0.267 1.202-1.834 1.31-0.48 2.159-0.962 1.039-1.811 2.19-3.12 2.881-0.113 1.153 1.554 0.526 1.44 1.68-0.802 1.122-1.209 3.907-2.641 3.6-0.806 0.247-0.373-0.746-0.479-1.199-0.89 0.295-1.405 0.67-2.16 0-0.26 0.78-0.709 1.371-1.2 1.92 1.643 1.478 4.003 2.237 5.521 3.84 3.235-1.359 7.077-5.149 10.8-1.92 0.188 0.988-0.368 1.231-0.24 2.16 0.896 0.774 0.978-0.801 1.92-0.721 1.06 0.062 1.265 0.976 2.16 1.2 0.185 0.904-0.293 1.147-0.24 1.92 0.473 0.889 2.352 0.368 2.881 1.2 0.555 2.155-1.012 2.188-0.961 3.84 1.031 0.388 1.998-1.142 3.601-0.96 0.884 1.517 0.381 4.419 2.16 5.04 0.628 3.104-2.561 3.75-4.32 2.4-0.444 0.436-0.312 1.448-0.72 1.92-1.188 0.147-1.536-0.545-2.4-0.721-0.799 1.563 1.617 1.889 0.72 3.601-1.775-0.463-2.337 1.205-3.359 2.16-1.136-0.064-1.352-1.049-2.16-1.44-0.217 0.423-0.884 0.396-0.96 0.96-0.752 0.804 1.801 1.3 0.72 2.4-1.513 2.06-3.329-1.013-5.76 0-0.55-0.57-1.208-1.032-1.44-1.92-2.051 0.131-3.084-0.756-4.319-1.44-3.303-0.538-4.311 1.677-7.44 0.96 0.216 2.23 3.326 2.419 5.28 2.16 2.783 2.896 3.368 7.992 6.72 10.32 0.458-3.125 4.479 6.161 9.12 10.319 3.707-0.149 6.219 0.33 8.16 1.44 0.042 1.242-2.057 0.343-2.64 0.96 1.246 2.751 4.993-0.816 6.96-0.24-0.479 6.364-12.435 7.859-14.881 2.16-6.689-3.79-9.293-11.666-15.119-16.32-2.059-0.502-3.208-1.912-4.801-2.88-5.372 0.134-10.436 0.287-13.92-1.92-2.16 1.263-3.17 4.747-6 5.521-2.923 0.798-5.911-0.139-8.16 1.92-7.446 1.033-14.465 2.494-19.68 5.76-1.237 0.412-2.52-0.162-3.12 0.479 0.48 2.32 1.668 3.934 1.92 6.48-0.519 0.761-0.962 1.598-1.92 1.92 0.095 1.746 2.833 0.848 3.12 2.4-4.069 1.981-6.507-1.59-7.92-3.841 0.508-4.2-0.333-9.392 2.16-11.52 1.205-1.029 2.837-0.545 4.32-1.68 4.366 0.4 8.705-2.869 12.96-3.84 4.858-1.109 9.547-1.108 11.279-5.28-1.414-1.656-3.291-0.841-5.52-1.44-1.111-0.299-1.463-1.133-2.4-1.68-0.562-0.328-1.474-0.334-2.16-0.72-2.196-1.234-3.287-3.257-6.239-3.841-1.489-0.294-2.832-0.085-4.08-0.479-7.656-2.422-10.618-10.302-13.2-18.24-0.314-3.445-0.995-6.524-1.92-9.359-0.827-8.533-7.048-11.673-13.68-14.4-2.024-0.184-3.309 0.372-5.28 0.24-0.977-0.784-2.486-1.034-2.16-3.12 1.78-0.307 3.603-1.558 5.52-0.96 1.04-0.164 1.452-1.567 2.636-2.16 1.045-0.523 3.934-0.583 5.52-1.92 0.24-0.202 4.291-0.067 4.561 0 2.813 0.7 2.876 4.102 5.04 5.76-1.263 4.763 2.796 8.095 3.6 12.24 0.192 0.99-0.095 1.896 0 2.88 0.472 4.913 2.428 11.467 4.8 14.88 0.998 1.438 2.397 2.623 4.078 3.6z"
fillRule="evenodd"></path>
</svg>
);
}

View File

@ -0,0 +1,33 @@
.spinner_V8m1 {
transform-origin: center;
animation: spinner_zKoa 2s linear infinite;
}
.spinner_V8m1 circle {
stroke-linecap: round;
animation: spinner_YpZS 1.5s ease-in-out infinite;
}
@keyframes spinner_zKoa {
100% {
transform: rotate(360deg);
}
}
@keyframes spinner_YpZS {
0% {
stroke-dasharray: 0 150;
stroke-dashoffset: 0;
}
47.5% {
stroke-dasharray: 42 150;
stroke-dashoffset: -16;
}
95%,
100% {
stroke-dasharray: 42 150;
stroke-dashoffset: -59;
}
}

View File

@ -0,0 +1,17 @@
import "./Spinner.css";
const Spinner = (props: { width?: number; height?: number; className?: string }) => (
<svg
width={props.width ?? 20}
height={props.height ?? 20}
stroke="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<g className="spinner_V8m1">
<circle cx="10" cy="10" r="7.5" fill="none" strokeWidth="3"></circle>
</g>
</svg>
);
export default Spinner;

Some files were not shown because too many files have changed in this diff Show More