reorganize code into smaller files & dirs
This commit is contained in:
28
packages/app/src/Components/Button/AsyncButton.css
Normal file
28
packages/app/src/Components/Button/AsyncButton.css
Normal 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;
|
||||
}
|
34
packages/app/src/Components/Button/AsyncButton.tsx
Normal file
34
packages/app/src/Components/Button/AsyncButton.tsx
Normal 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;
|
24
packages/app/src/Components/Button/AsyncIcon.tsx
Normal file
24
packages/app/src/Components/Button/AsyncIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
packages/app/src/Components/Button/BackButton.css
Normal file
22
packages/app/src/Components/Button/BackButton.css
Normal 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;
|
||||
}
|
29
packages/app/src/Components/Button/BackButton.tsx
Normal file
29
packages/app/src/Components/Button/BackButton.tsx
Normal 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;
|
15
packages/app/src/Components/Button/CloseButton.tsx
Normal file
15
packages/app/src/Components/Button/CloseButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
21
packages/app/src/Components/Button/IconButton.tsx
Normal file
21
packages/app/src/Components/Button/IconButton.tsx
Normal 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;
|
24
packages/app/src/Components/Button/LogoutButton.tsx
Normal file
24
packages/app/src/Components/Button/LogoutButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
packages/app/src/Components/Button/NavLink.tsx
Normal file
20
packages/app/src/Components/Button/NavLink.tsx
Normal 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} />;
|
||||
}
|
57
packages/app/src/Components/Collapsed.tsx
Normal file
57
packages/app/src/Components/Collapsed.tsx
Normal 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;
|
54
packages/app/src/Components/CommunityLeaders/Award.tsx
Normal file
54
packages/app/src/Components/CommunityLeaders/Award.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
packages/app/src/Components/CommunityLeaders/LeaderBadge.tsx
Normal file
48
packages/app/src/Components/CommunityLeaders/LeaderBadge.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
4
packages/app/src/Components/Copy/Copy.css
Normal file
4
packages/app/src/Components/Copy/Copy.css
Normal file
@ -0,0 +1,4 @@
|
||||
.copy .copy-body {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--font-color);
|
||||
}
|
24
packages/app/src/Components/Copy/Copy.tsx
Normal file
24
packages/app/src/Components/Copy/Copy.tsx
Normal 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>
|
||||
);
|
||||
}
|
16
packages/app/src/Components/Embed/AppleMusicEmbed.tsx
Normal file
16
packages/app/src/Components/Embed/AppleMusicEmbed.tsx
Normal 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;
|
8
packages/app/src/Components/Embed/CashuNuts.css
Normal file
8
packages/app/src/Components/Embed/CashuNuts.css
Normal file
@ -0,0 +1,8 @@
|
||||
.cashu {
|
||||
background: var(--cashu-gradient);
|
||||
}
|
||||
|
||||
.cashu h1 {
|
||||
font-size: 44px;
|
||||
line-height: 1em;
|
||||
}
|
139
packages/app/src/Components/Embed/CashuNuts.tsx
Normal file
139
packages/app/src/Components/Embed/CashuNuts.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
packages/app/src/Components/Embed/Hashtag.css
Normal file
3
packages/app/src/Components/Embed/Hashtag.css
Normal file
@ -0,0 +1,3 @@
|
||||
.hashtag {
|
||||
color: var(--highlight);
|
||||
}
|
14
packages/app/src/Components/Embed/Hashtag.tsx
Normal file
14
packages/app/src/Components/Embed/Hashtag.tsx
Normal 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;
|
99
packages/app/src/Components/Embed/HyperText.tsx
Normal file
99
packages/app/src/Components/Embed/HyperText.tsx
Normal 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>
|
||||
);
|
||||
}
|
95
packages/app/src/Components/Embed/Invoice.css
Normal file
95
packages/app/src/Components/Embed/Invoice.css
Normal 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);
|
||||
}
|
90
packages/app/src/Components/Embed/Invoice.tsx
Normal file
90
packages/app/src/Components/Embed/Invoice.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
64
packages/app/src/Components/Embed/LinkPreview.css
Normal file
64
packages/app/src/Components/Embed/LinkPreview.css
Normal 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;
|
||||
}
|
||||
}
|
89
packages/app/src/Components/Embed/LinkPreview.tsx
Normal file
89
packages/app/src/Components/Embed/LinkPreview.tsx
Normal 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;
|
22
packages/app/src/Components/Embed/MagnetLink.tsx
Normal file
22
packages/app/src/Components/Embed/MagnetLink.tsx
Normal 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;
|
122
packages/app/src/Components/Embed/MediaElement.tsx
Normal file
122
packages/app/src/Components/Embed/MediaElement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
35
packages/app/src/Components/Embed/Mention.tsx
Normal file
35
packages/app/src/Components/Embed/Mention.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
packages/app/src/Components/Embed/MixCloudEmbed.tsx
Normal file
23
packages/app/src/Components/Embed/MixCloudEmbed.tsx
Normal 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;
|
34
packages/app/src/Components/Embed/NostrLink.tsx
Normal file
34
packages/app/src/Components/Embed/NostrLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
5
packages/app/src/Components/Embed/NostrNestsEmbed.tsx
Normal file
5
packages/app/src/Components/Embed/NostrNestsEmbed.tsx
Normal 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;
|
85
packages/app/src/Components/Embed/PubkeyList.tsx
Normal file
85
packages/app/src/Components/Embed/PubkeyList.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
12
packages/app/src/Components/Embed/SoundCloudEmded.tsx
Normal file
12
packages/app/src/Components/Embed/SoundCloudEmded.tsx
Normal 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;
|
16
packages/app/src/Components/Embed/SpotifyEmbed.tsx
Normal file
16
packages/app/src/Components/Embed/SpotifyEmbed.tsx
Normal 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;
|
57
packages/app/src/Components/Embed/TidalEmbed.tsx
Normal file
57
packages/app/src/Components/Embed/TidalEmbed.tsx
Normal 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;
|
8
packages/app/src/Components/Embed/TwitchEmbed.tsx
Normal file
8
packages/app/src/Components/Embed/TwitchEmbed.tsx
Normal 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;
|
15
packages/app/src/Components/Embed/WavlakeEmbed.tsx
Normal file
15
packages/app/src/Components/Embed/WavlakeEmbed.tsx
Normal 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;
|
20
packages/app/src/Components/Embed/ZapstrEmbed.css
Normal file
20
packages/app/src/Components/Embed/ZapstrEmbed.css
Normal 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;
|
||||
}
|
39
packages/app/src/Components/Embed/ZapstrEmbed.tsx
Normal file
39
packages/app/src/Components/Embed/ZapstrEmbed.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
39
packages/app/src/Components/ErrorBoundary.tsx
Normal file
39
packages/app/src/Components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
25
packages/app/src/Components/ErrorOrOffline.tsx
Normal file
25
packages/app/src/Components/ErrorOrOffline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
122
packages/app/src/Components/Event/Create/NoteCreator.css
Normal file
122
packages/app/src/Components/Event/Create/NoteCreator.css
Normal 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);
|
||||
}
|
691
packages/app/src/Components/Event/Create/NoteCreator.tsx
Normal file
691
packages/app/src/Components/Event/Create/NoteCreator.tsx
Normal 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> </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>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
64
packages/app/src/Components/Event/Create/OkResponseRow.tsx
Normal file
64
packages/app/src/Components/Event/Create/OkResponseRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
28
packages/app/src/Components/Event/Create/util.ts
Normal file
28
packages/app/src/Components/Event/Create/util.ts
Normal 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;
|
||||
}
|
||||
}
|
15
packages/app/src/Components/Event/FileUpload.tsx
Normal file
15
packages/app/src/Components/Event/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
packages/app/src/Components/Event/HiddenNote.tsx
Normal file
23
packages/app/src/Components/Event/HiddenNote.tsx
Normal 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;
|
67
packages/app/src/Components/Event/LongFormText.css
Normal file
67
packages/app/src/Components/Event/LongFormText.css
Normal 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;
|
||||
}
|
||||
}
|
168
packages/app/src/Components/Event/LongFormText.tsx
Normal file
168
packages/app/src/Components/Event/LongFormText.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
packages/app/src/Components/Event/Markdown.css
Normal file
44
packages/app/src/Components/Event/Markdown.css
Normal 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;
|
||||
}
|
136
packages/app/src/Components/Event/Markdown.tsx
Normal file
136
packages/app/src/Components/Event/Markdown.tsx
Normal 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 };
|
51
packages/app/src/Components/Event/NostrFileHeader.tsx
Normal file
51
packages/app/src/Components/Event/NostrFileHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
176
packages/app/src/Components/Event/Note.css
Normal file
176
packages/app/src/Components/Event/Note.css
Normal 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);
|
||||
}
|
90
packages/app/src/Components/Event/Note.tsx
Normal file
90
packages/app/src/Components/Event/Note.tsx
Normal 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>;
|
||||
}
|
213
packages/app/src/Components/Event/NoteContextMenu.tsx
Normal file
213
packages/app/src/Components/Event/NoteContextMenu.tsx
Normal 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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
329
packages/app/src/Components/Event/NoteFooter.tsx
Normal file
329
packages/app/src/Components/Event/NoteFooter.tsx
Normal 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";
|
20
packages/app/src/Components/Event/NoteGhost.tsx
Normal file
20
packages/app/src/Components/Event/NoteGhost.tsx
Normal 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>
|
||||
);
|
||||
}
|
415
packages/app/src/Components/Event/NoteInner.tsx
Normal file
415
packages/app/src/Components/Event/NoteInner.tsx
Normal 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] && (
|
||||
<>
|
||||
|
||||
<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:
|
||||
{(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;
|
||||
}
|
27
packages/app/src/Components/Event/NoteQuote.tsx
Normal file
27
packages/app/src/Components/Event/NoteQuote.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
14
packages/app/src/Components/Event/NoteReaction.css
Normal file
14
packages/app/src/Components/Event/NoteReaction.css
Normal 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;
|
||||
}
|
99
packages/app/src/Components/Event/NoteReaction.tsx
Normal file
99
packages/app/src/Components/Event/NoteReaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
74
packages/app/src/Components/Event/NoteTime.tsx
Normal file
74
packages/app/src/Components/Event/NoteTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
178
packages/app/src/Components/Event/Poll.tsx
Normal file
178
packages/app/src/Components/Event/Poll.tsx
Normal 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} />%
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
}
|
117
packages/app/src/Components/Event/Reactions.css
Normal file
117
packages/app/src/Components/Event/Reactions.css
Normal 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;
|
||||
}
|
||||
}
|
144
packages/app/src/Components/Event/Reactions.tsx
Normal file
144
packages/app/src/Components/Event/Reactions.tsx
Normal 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;
|
17
packages/app/src/Components/Event/Reveal.tsx
Normal file
17
packages/app/src/Components/Event/Reveal.tsx
Normal 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;
|
||||
}
|
||||
}
|
89
packages/app/src/Components/Event/RevealMedia.tsx
Normal file
89
packages/app/src/Components/Event/RevealMedia.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
18
packages/app/src/Components/Event/ShowMore.css
Normal file
18
packages/app/src/Components/Event/ShowMore.css
Normal 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;
|
||||
}
|
39
packages/app/src/Components/Event/ShowMore.tsx
Normal file
39
packages/app/src/Components/Event/ShowMore.tsx
Normal 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>
|
||||
);
|
||||
}
|
101
packages/app/src/Components/Event/Thread.css
Normal file
101
packages/app/src/Components/Event/Thread.css
Normal 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;
|
||||
}
|
354
packages/app/src/Components/Event/Thread.tsx
Normal file
354
packages/app/src/Components/Event/Thread.tsx
Normal 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 : [];
|
||||
}
|
93
packages/app/src/Components/Event/Zap.css
Normal file
93
packages/app/src/Components/Event/Zap.css
Normal 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;
|
||||
}
|
||||
}
|
79
packages/app/src/Components/Event/Zap.tsx
Normal file
79
packages/app/src/Components/Event/Zap.tsx
Normal 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;
|
7
packages/app/src/Components/Event/ZapButton.css
Normal file
7
packages/app/src/Components/Event/ZapButton.css
Normal file
@ -0,0 +1,7 @@
|
||||
.zap-button {
|
||||
color: var(--bg-color);
|
||||
background-color: var(--highlight);
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
}
|
50
packages/app/src/Components/Event/ZapButton.tsx
Normal file
50
packages/app/src/Components/Event/ZapButton.tsx
Normal 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;
|
3
packages/app/src/Components/Event/ZapGoal.css
Normal file
3
packages/app/src/Components/Event/ZapGoal.css
Normal file
@ -0,0 +1,3 @@
|
||||
.zap-goal h1 {
|
||||
line-height: 1em;
|
||||
}
|
41
packages/app/src/Components/Event/ZapGoal.tsx
Normal file
41
packages/app/src/Components/Event/ZapGoal.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
packages/app/src/Components/Event/getEventMedia.ts
Normal file
9
packages/app/src/Components/Event/getEventMedia.ts
Normal 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/")),
|
||||
);
|
||||
}
|
37
packages/app/src/Components/Feed/Articles.tsx
Normal file
37
packages/app/src/Components/Feed/Articles.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
47
packages/app/src/Components/Feed/DisplayAsSelector.tsx
Normal file
47
packages/app/src/Components/Feed/DisplayAsSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
33
packages/app/src/Components/Feed/Generic.tsx
Normal file
33
packages/app/src/Components/Feed/Generic.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
37
packages/app/src/Components/Feed/ImageGridItem.tsx
Normal file
37
packages/app/src/Components/Feed/ImageGridItem.tsx
Normal 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;
|
37
packages/app/src/Components/Feed/LoadMore.tsx
Normal file
37
packages/app/src/Components/Feed/LoadMore.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
packages/app/src/Components/Feed/RootTabs.css
Normal file
23
packages/app/src/Components/Feed/RootTabs.css
Normal 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;
|
||||
}
|
191
packages/app/src/Components/Feed/RootTabs.tsx
Normal file
191
packages/app/src/Components/Feed/RootTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
packages/app/src/Components/Feed/Timeline.css
Normal file
38
packages/app/src/Components/Feed/Timeline.css
Normal 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;
|
||||
}
|
115
packages/app/src/Components/Feed/Timeline.tsx
Normal file
115
packages/app/src/Components/Feed/Timeline.tsx
Normal 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;
|
139
packages/app/src/Components/Feed/TimelineFollows.tsx
Normal file
139
packages/app/src/Components/Feed/TimelineFollows.tsx
Normal 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;
|
51
packages/app/src/Components/Feed/TimelineFragment.tsx
Normal file
51
packages/app/src/Components/Feed/TimelineFragment.tsx
Normal 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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
153
packages/app/src/Components/Feed/TimelineRenderer.tsx
Normal file
153
packages/app/src/Components/Feed/TimelineRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
31
packages/app/src/Components/Feed/UsersFeed.tsx
Normal file
31
packages/app/src/Components/Feed/UsersFeed.tsx
Normal 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} />;
|
||||
}
|
5
packages/app/src/Components/HighlightedText.tsx
Normal file
5
packages/app/src/Components/HighlightedText.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
const HighlightedText = ({ content }: { content: string }) => {
|
||||
return <strong className="highlighted-text">{content}</strong>;
|
||||
};
|
||||
|
||||
export default HighlightedText;
|
75
packages/app/src/Components/Icons/Alby.tsx
Normal file
75
packages/app/src/Components/Icons/Alby.tsx
Normal 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>
|
||||
);
|
||||
}
|
59
packages/app/src/Components/Icons/BlueWallet.tsx
Normal file
59
packages/app/src/Components/Icons/BlueWallet.tsx
Normal file
File diff suppressed because one or more lines are too long
48
packages/app/src/Components/Icons/Cashu.tsx
Normal file
48
packages/app/src/Components/Icons/Cashu.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
packages/app/src/Components/Icons/Icon.tsx
Normal file
23
packages/app/src/Components/Icons/Icon.tsx
Normal 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;
|
11
packages/app/src/Components/Icons/Nostrich.tsx
Normal file
11
packages/app/src/Components/Icons/Nostrich.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
packages/app/src/Components/Icons/Spinner.css
Normal file
33
packages/app/src/Components/Icons/Spinner.css
Normal 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;
|
||||
}
|
||||
}
|
17
packages/app/src/Components/Icons/Spinner.tsx
Normal file
17
packages/app/src/Components/Icons/Spinner.tsx
Normal 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
Reference in New Issue
Block a user