workspace with decoupled nostr package

This commit is contained in:
ennmichael
2023-02-11 21:05:46 +01:00
parent 52e0809622
commit 2a211b78a1
260 changed files with 2363 additions and 714 deletions

View File

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

View File

@ -0,0 +1,31 @@
import { useState } from "react";
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onClick(e: React.MouseEvent): Promise<void> | void;
children?: React.ReactNode;
}
export default function AsyncButton(props: AsyncButtonProps) {
const [loading, setLoading] = useState<boolean>(false);
async function handle(e: React.MouseEvent) {
if (loading) return;
setLoading(true);
try {
if (typeof props.onClick === "function") {
const f = props.onClick(e);
if (f instanceof Promise) {
await f;
}
}
} finally {
setLoading(false);
}
}
return (
<button type="button" disabled={loading} {...props} onClick={handle}>
{props.children}
</button>
);
}

View File

@ -0,0 +1,19 @@
.avatar {
border-radius: 50%;
height: 210px;
width: 210px;
background-image: var(--img-url);
border: 1px solid transparent;
background-origin: border-box;
background-clip: content-box, border-box;
background-size: cover;
box-sizing: border-box;
}
.avatar[data-domain="snort.social"] {
background-image: var(--img-url), var(--snort-gradient);
}
.avatar[data-domain="strike.army"] {
background-image: var(--img-url), var(--strike-army-gradient);
}

View File

@ -0,0 +1,25 @@
import "./Avatar.css";
import Nostrich from "nostrich.webp";
import { CSSProperties, useEffect, useState } from "react";
import type { UserMetadata } from "@snort/nostr";
import useImgProxy from "Feed/ImgProxy";
const Avatar = ({ user, ...rest }: { user?: UserMetadata; onClick?: () => void }) => {
const [url, setUrl] = useState<string>(Nostrich);
const { proxy } = useImgProxy();
useEffect(() => {
if (user?.picture) {
proxy(user.picture, 120)
.then(a => setUrl(a))
.catch(console.warn);
}
}, [user]);
const backgroundImage = `url(${url})`;
const style = { "--img-url": backgroundImage } as CSSProperties;
const domain = user?.nip05 && user.nip05.split("@")[1];
return <div {...rest} style={style} className="avatar" data-domain={domain?.toLowerCase()}></div>;
};
export default Avatar;

View File

@ -0,0 +1,21 @@
.back-button {
background: none;
padding: 0;
color: var(--highlight);
font-weight: 400;
font-size: var(--font-size);
}
.back-button svg {
margin-right: 0.5em;
}
.back-button:hover {
text-decoration: underline;
}
.back-button:hover {
background: none;
color: var(--font-color);
text-decoration: underline;
}

View File

@ -0,0 +1,29 @@
import "./BackButton.css";
import { useIntl } from "react-intl";
import ArrowBack from "Icons/ArrowBack";
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}>
<ArrowBack />
{text || formatMessage(messages.Back)}
</button>
);
};
export default BackButton;

View File

@ -0,0 +1,24 @@
import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
interface BlockButtonProps {
pubkey: HexKey;
}
const BlockButton = ({ pubkey }: BlockButtonProps) => {
const { block, unblock, isBlocked } = useModeration();
return isBlocked(pubkey) ? (
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
<FormattedMessage {...messages.Unblock} />
</button>
) : (
<button className="secondary" type="button" onClick={() => block(pubkey)}>
<FormattedMessage {...messages.Block} />
</button>
);
};
export default BlockButton;

View File

@ -0,0 +1,42 @@
import { FormattedMessage } from "react-intl";
import MuteButton from "Element/MuteButton";
import BlockButton from "Element/BlockButton";
import ProfilePreview from "Element/ProfilePreview";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
interface BlockListProps {
variant: "muted" | "blocked";
}
export default function BlockList({ variant }: BlockListProps) {
const { blocked, muted } = useModeration();
return (
<div className="main-content">
{variant === "muted" && (
<>
<h4>
<FormattedMessage {...messages.MuteCount} values={{ n: muted.length }} />
</h4>
{muted.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
})}
</>
)}
{variant === "blocked" && (
<>
<h4>
<FormattedMessage {...messages.BlockCount} values={{ n: blocked.length }} />
</h4>
{blocked.map(a => {
return (
<ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
);
})}
</>
)}
</div>
);
}

View File

@ -0,0 +1,61 @@
import { useState, useMemo, ChangeEvent } from "react";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import { dedupeByPubkey } from "Util";
import Note from "Element/Note";
import { HexKey, TaggedRawEvent } from "Nostr";
import { useUserProfiles } from "Feed/ProfileFeed";
import { RootState } from "State/Store";
import messages from "./messages";
interface BookmarksProps {
pubkey: HexKey;
bookmarks: TaggedRawEvent[];
related: TaggedRawEvent[];
}
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
const ps = useMemo(() => {
return dedupeByPubkey(bookmarks).map(ev => ev.pubkey);
}, [bookmarks]);
const profiles = useUserProfiles(ps);
function renderOption(p: HexKey) {
const profile = profiles?.get(p);
return profile ? <option value={p}>{profile?.display_name || profile?.name}</option> : null;
}
return (
<div className="main-content">
<div className="icon-title">
<select
disabled={ps.length <= 1}
value={onlyPubkey}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setOnlyPubkey(e.target.value)}>
<option value="all">
<FormattedMessage {...messages.All} />
</option>
{ps.map(renderOption)}
</select>
</div>
{bookmarks
.filter(b => (onlyPubkey === "all" ? true : b.pubkey === onlyPubkey))
.map(n => {
return (
<Note
key={n.id}
data={n}
related={related}
options={{ showTime: false, showBookmarked: true, canUnbookmark: loginPubKey === pubkey }}
/>
);
})}
</div>
);
};
export default Bookmarks;

View File

@ -0,0 +1,56 @@
import { useState, ReactNode } from "react";
import ChevronDown from "Icons/ChevronDown";
import ShowMore from "Element/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;
}
export const CollapsedSection = ({ title, children }: CollapsedSectionProps) => {
const [collapsed, setCollapsed] = useState(true);
const icon = (
<div className={`collapse-icon ${collapsed ? "" : "flip"}`} onClick={() => setCollapsed(!collapsed)}>
<ChevronDown />
</div>
);
return (
<>
<div className="collapsable-section">
<h3 onClick={() => setCollapsed(!collapsed)}>{title}</h3>
<CollapsedIcon icon={icon} collapsed={collapsed} />
</div>
{collapsed ? null : children}
</>
);
};
export default Collapsed;

View File

@ -0,0 +1,14 @@
.copy {
cursor: pointer;
align-items: center;
}
.copy .body {
font-size: var(--font-size-small);
color: var(--font-color);
margin-right: 6px;
}
.copy .icon {
margin-bottom: -4px;
}

View File

@ -0,0 +1,23 @@
import "./Copy.css";
import Check from "Icons/Check";
import CopyIcon from "Icons/Copy";
import { useCopy } from "useCopy";
export interface CopyProps {
text: string;
maxSize?: number;
}
export default function Copy({ text, maxSize = 32 }: 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="flex flex-row copy" onClick={() => copy(text)}>
<span className="body">{trimmed}</span>
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Check width={13} height={13} /> : <CopyIcon width={13} height={13} />}
</span>
</div>
);
}

View File

@ -0,0 +1,23 @@
.dm {
padding: 8px;
background-color: var(--gray);
margin-bottom: 5px;
border-radius: 5px;
width: fit-content;
min-width: 100px;
max-width: 90%;
overflow: hidden;
min-height: 40px;
white-space: pre-wrap;
}
.dm > div:first-child {
color: var(--gray-light);
font-size: small;
margin-bottom: 3px;
}
.dm.me {
align-self: flex-end;
background-color: var(--gray-secondary);
}

View File

@ -0,0 +1,61 @@
import "./DM.css";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useIntl } from "react-intl";
import { useInView } from "react-intersection-observer";
import useEventPublisher from "Feed/EventPublisher";
import { Event } from "@snort/nostr";
import NoteTime from "Element/NoteTime";
import Text from "Element/Text";
import { setLastReadDm } from "Pages/MessagesPage";
import { RootState } from "State/Store";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import { incDmInteraction } from "State/Login";
import { unwrap } from "Util";
import messages from "./messages";
export type DMProps = {
data: TaggedRawEvent;
};
export default function DM(props: DMProps) {
const dispatch = useDispatch();
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const publisher = useEventPublisher();
const [content, setContent] = useState("Loading...");
const [decrypted, setDecrypted] = useState(false);
const { ref, inView } = useInView();
const { formatMessage } = useIntl();
const isMe = props.data.pubkey === pubKey;
const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]);
async function decrypt() {
const e = new Event(props.data);
const decrypted = await publisher.decryptDm(e);
setContent(decrypted || "<ERROR>");
if (!isMe) {
setLastReadDm(e.PubKey);
dispatch(incDmInteraction());
}
}
useEffect(() => {
if (!decrypted && inView) {
setDecrypted(true);
decrypt().catch(console.error);
}
}, [inView, props.data]);
return (
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
<div>
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
</div>
<div className="w-max">
<Text content={content} tags={[]} users={new Map()} creator={otherPubkey} />
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
.follow-button {
}

View File

@ -0,0 +1,39 @@
import "./FollowButton.css";
import { useSelector } from "react-redux";
import { FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher";
import { HexKey } from "@snort/nostr";
import { RootState } from "State/Store";
import { parseId } from "Util";
import messages from "./messages";
export interface FollowButtonProps {
pubkey: HexKey;
className?: string;
}
export default function FollowButton(props: FollowButtonProps) {
const pubkey = parseId(props.pubkey);
const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
const baseClassname = `${props.className} follow-button`;
async function follow(pubkey: HexKey) {
const ev = await publiser.addFollow(pubkey);
publiser.broadcast(ev);
}
async function unfollow(pubkey: HexKey) {
const ev = await publiser.removeFollow(pubkey);
publiser.broadcast(ev);
}
return (
<button
type="button"
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
onClick={() => (isFollowing ? unfollow(pubkey) : follow(pubkey))}>
{isFollowing ? <FormattedMessage {...messages.Unfollow} /> : <FormattedMessage {...messages.Follow} />}
</button>
);
}

View File

@ -0,0 +1,35 @@
import { ReactNode } from "react";
import { FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher";
import { HexKey } from "@snort/nostr";
import ProfilePreview from "Element/ProfilePreview";
import messages from "./messages";
export interface FollowListBaseProps {
pubkeys: HexKey[];
title?: ReactNode | string;
}
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) {
const publisher = useEventPublisher();
async function followAll() {
const ev = await publisher.addFollow(pubkeys);
publisher.broadcast(ev);
}
return (
<div className="main-content">
<div className="flex mt10 mb10">
<div className="f-grow bold">{title}</div>
<button className="transparent" type="button" onClick={() => followAll()}>
<FormattedMessage {...messages.FollowAll} />
</button>
</div>
{pubkeys?.map(a => (
<ProfilePreview pubkey={a} key={a} />
))}
</div>
);
}

View File

@ -0,0 +1,27 @@
import { useMemo } from "react";
import { useIntl } from "react-intl";
import useFollowersFeed from "Feed/FollowersFeed";
import { HexKey } from "@snort/nostr";
import { EventKind } from "@snort/nostr";
import FollowListBase from "Element/FollowListBase";
import messages from "./messages";
export interface FollowersListProps {
pubkey: HexKey;
}
export default function FollowersList({ pubkey }: FollowersListProps) {
const { formatMessage } = useIntl();
const feed = useFollowersFeed(pubkey);
const pubkeys = useMemo(() => {
const contactLists = feed?.store.notes.filter(
a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)
);
return [...new Set(contactLists?.map(a => a.pubkey))];
}, [feed, pubkey]);
return <FollowListBase pubkeys={pubkeys} title={formatMessage(messages.FollowerCount, { n: pubkeys?.length })} />;
}

View File

@ -0,0 +1,24 @@
import { useMemo } from "react";
import { useIntl } from "react-intl";
import useFollowsFeed from "Feed/FollowsFeed";
import { HexKey } from "@snort/nostr";
import FollowListBase from "Element/FollowListBase";
import { getFollowers } from "Feed/FollowsFeed";
import messages from "./messages";
export interface FollowsListProps {
pubkey: HexKey;
}
export default function FollowsList({ pubkey }: FollowsListProps) {
const feed = useFollowsFeed(pubkey);
const { formatMessage } = useIntl();
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed, pubkey]);
return <FollowListBase pubkeys={pubkeys} title={formatMessage(messages.FollowingCount, { n: pubkeys?.length })} />;
}

View File

@ -0,0 +1,6 @@
.follows-you {
color: var(--font-secondary-color);
font-size: var(--font-size-tiny);
margin-left: 0.2em;
font-weight: normal;
}

View File

@ -0,0 +1,29 @@
import "./FollowsYou.css";
import { useMemo } from "react";
import { useSelector } from "react-redux";
import { useIntl } from "react-intl";
import { HexKey } from "@snort/nostr";
import { RootState } from "State/Store";
import useFollowsFeed from "Feed/FollowsFeed";
import { getFollowers } from "Feed/FollowsFeed";
import messages from "./messages";
export interface FollowsYouProps {
pubkey: HexKey;
}
export default function FollowsYou({ pubkey }: FollowsYouProps) {
const { formatMessage } = useIntl();
const feed = useFollowsFeed(pubkey);
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const pubkeys = useMemo(() => {
return getFollowers(feed.store, pubkey);
}, [feed, pubkey]);
const followsMe = loginPubKey ? pubkeys.includes(loginPubKey) : false;
return followsMe ? <span className="follows-you">{formatMessage(messages.FollowsYou)}</span> : null;
}

View File

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

View File

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

View File

@ -0,0 +1,140 @@
import { useCallback } from "react";
import { useSelector } from "react-redux";
import { TwitterTweetEmbed } from "react-twitter-embed";
import {
FileExtensionRegex,
YoutubeUrlRegex,
TweetUrlRegex,
TidalRegex,
SoundCloudRegex,
MixCloudRegex,
SpotifyRegex,
TwitchRegex,
AppleMusicRegex,
} from "Const";
import { RootState } from "State/Store";
import SoundCloudEmbed from "Element/SoundCloudEmded";
import MixCloudEmbed from "Element/MixCloudEmbed";
import SpotifyEmbed from "Element/SpotifyEmbed";
import TidalEmbed from "Element/TidalEmbed";
import { ProxyImg } from "Element/ProxyImg";
import { HexKey } from "@snort/nostr";
import TwitchEmbed from "./TwitchEmbed";
import AppleMusicEmbed from "./AppleMusicEmbed";
export default function HyperText({ link, creator }: { link: string; creator: HexKey }) {
const pref = useSelector((s: RootState) => s.login.preferences);
const follows = useSelector((s: RootState) => s.login.follows);
const publicKey = useSelector((s: RootState) => s.login.publicKey);
const render = useCallback(() => {
const a = link;
try {
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(creator);
const isMine = creator === publicKey;
if (pref.autoLoadMedia === "none" || (!isMine && hideNonFollows)) {
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
);
}
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
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 extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
if (extension && !isAppleMusicLink) {
switch (extension) {
case "gif":
case "jpg":
case "jpeg":
case "png":
case "bmp":
case "webp": {
return <ProxyImg key={url.toString()} src={url.toString()} />;
}
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />;
}
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v": {
return <video key={url.toString()} src={url.toString()} controls />;
}
default:
return (
<a
key={url.toString()}
href={url.toString()}
onClick={e => e.stopPropagation()}
target="_blank"
rel="noreferrer"
className="ext">
{url.toString()}
</a>
);
}
} else if (tweetId) {
return (
<div className="tweet" key={tweetId}>
<TwitterTweetEmbed tweetId={tweetId} />
</div>
);
} else if (youtubeId) {
return (
<>
<br />
<iframe
className="w-max"
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}
/>
<br />
</>
);
} 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 {
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
);
}
} catch (error) {
// Ignore the error.
}
return (
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{a}
</a>
);
}, [link]);
return render();
}

View File

@ -0,0 +1,16 @@
import type { ReactNode } from "react";
interface IconButtonProps {
onClick(): void;
children: ReactNode;
}
const IconButton = ({ onClick, children }: IconButtonProps) => {
return (
<button className="icon" type="button" onClick={onClick}>
<div className="icon-wrapper">{children}</div>
</button>
);
};
export default IconButton;

View File

@ -0,0 +1,88 @@
.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.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;
}

View File

@ -0,0 +1,114 @@
import "./Invoice.css";
import { useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { decode as invoiceDecode } from "light-bolt11-decoder";
import { useMemo } from "react";
import SendSats from "Element/SendSats";
import ZapCircle from "Icons/ZapCircle";
import useWebln from "Hooks/useWebln";
import messages from "./messages";
export interface InvoiceProps {
invoice: string;
}
export default function Invoice(props: InvoiceProps) {
const invoice = props.invoice;
const webln = useWebln();
const [showInvoice, setShowInvoice] = useState(false);
const { formatMessage } = useIntl();
const info = useMemo(() => {
try {
const parsed = invoiceDecode(invoice);
const amountSection = parsed.sections.find(a => a.name === "amount");
const amount = amountSection ? (amountSection.value as number) : NaN;
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
const timestamp = timestampSection ? (timestampSection.value as number) : NaN;
const expirySection = parsed.sections.find(a => a.name === "expiry");
const expire = expirySection ? (expirySection.value as number) : NaN;
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
const ret = {
amount: !isNaN(amount) ? amount / 1000 : 0,
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
description: descriptionSection as string | undefined,
expired: false,
};
if (ret.expire) {
ret.expired = ret.expire < new Date().getTime() / 1000;
}
return ret;
} catch (e) {
console.error(e);
}
}, [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>
<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 (webln?.enabled) {
try {
await webln.sendPayment(invoice);
setIsPaid(true);
} catch (error) {
setShowInvoice(true);
}
} else {
setShowInvoice(true);
}
}
return (
<>
<div className={`note-invoice flex ${isExpired ? "expired" : ""} ${isPaid ? "paid" : ""}`}>
<div className="invoice-header">{header()}</div>
<p className="invoice-amount">
{amount > 0 && (
<>
{amount.toLocaleString()} <span className="sats">sat{amount === 1 ? "" : "s"}</span>
</>
)}
</p>
<div className="invoice-body">
{description && <p>{description}</p>}
{isPaid ? (
<div className="paid">
<FormattedMessage {...messages.Paid} />
</div>
) : (
<button disabled={isExpired} type="button" onClick={payInvoice}>
{isExpired ? <FormattedMessage {...messages.Expired} /> : <FormattedMessage {...messages.Pay} />}
</button>
)}
</div>
</div>
</>
);
}

View File

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

View File

@ -0,0 +1,23 @@
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { logout } from "State/Login";
import messages from "./messages";
export default function LogoutButton() {
const dispatch = useDispatch();
const navigate = useNavigate();
return (
<button
className="secondary"
type="button"
onClick={() => {
dispatch(logout());
navigate("/");
}}>
<FormattedMessage {...messages.Logout} />
</button>
);
}

View File

@ -0,0 +1,25 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "@snort/nostr";
import { hexToBech32, profileLink } from "Util";
export default function Mention({ pubkey }: { pubkey: HexKey }) {
const user = useUserProfile(pubkey);
const name = useMemo(() => {
let name = hexToBech32("npub", pubkey).substring(0, 12);
if (user?.display_name !== undefined && user.display_name.length > 0) {
name = user.display_name;
} else if (user?.name !== undefined && user.name.length > 0) {
name = user.name;
}
return name;
}, [user, pubkey]);
return (
<Link to={profileLink(pubkey)} onClick={e => e.stopPropagation()}>
@{name}
</Link>
);
}

View File

@ -0,0 +1,26 @@
import { MixCloudRegex } from "Const";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
const MixCloudEmbed = ({ link }: { link: string }) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
const lightTheme = useSelector<RootState, boolean>(s => s.login.preferences.theme === "light");
const lightParams = lightTheme ? "light=1" : "light=0";
return (
<>
<br />
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
/>
</>
);
};
export default MixCloudEmbed;

View File

@ -0,0 +1,27 @@
.modal {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background-color: var(--modal-bg-color);
display: flex;
justify-content: center;
align-items: center;
z-index: 42;
}
.modal-body {
background-color: var(--note-bg);
padding: 10px;
border-radius: 10px;
width: 500px;
min-height: 10vh;
}
@media (max-width: 720px) {
.modal-body {
width: 100vw;
margin: 0 10px;
}
}

View File

@ -0,0 +1,43 @@
import "./Modal.css";
import { useEffect, useRef } from "react";
import * as React from "react";
export interface ModalProps {
className?: string;
onClose?: () => void;
children: React.ReactNode;
}
function useOnClickOutside(ref: React.MutableRefObject<Element | null>, onClickOutside: () => void) {
useEffect(() => {
function handleClickOutside(ev: MouseEvent) {
if (ref && ref.current && !ref.current.contains(ev.target as Node)) {
onClickOutside();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref]);
}
export default function Modal(props: ModalProps) {
const ref = useRef(null);
const onClose = props.onClose || (() => undefined);
const className = props.className || "";
useOnClickOutside(ref, onClose);
useEffect(() => {
document.body.classList.add("scroll-lock");
return () => document.body.classList.remove("scroll-lock");
}, []);
return (
<div className={`modal ${className}`}>
<div ref={ref} className="modal-body">
{props.children}
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
interface MuteButtonProps {
pubkey: HexKey;
}
const MuteButton = ({ pubkey }: MuteButtonProps) => {
const { mute, unmute, isMuted } = useModeration();
return isMuted(pubkey) ? (
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
<FormattedMessage {...messages.Unmute} />
</button>
) : (
<button type="button" onClick={() => mute(pubkey)}>
<FormattedMessage {...messages.Mute} />
</button>
);
};
export default MuteButton;

View File

@ -0,0 +1,42 @@
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr";
import MuteButton from "Element/MuteButton";
import ProfilePreview from "Element/ProfilePreview";
import useMutedFeed, { getMuted } from "Feed/MuteList";
import useModeration from "Hooks/useModeration";
import messages from "./messages";
export interface MutedListProps {
pubkey: HexKey;
}
export default function MutedList({ pubkey }: MutedListProps) {
const { isMuted, muteAll } = useModeration();
const feed = useMutedFeed(pubkey);
const pubkeys = useMemo(() => {
return getMuted(feed.store, pubkey);
}, [feed, pubkey]);
const hasAllMuted = pubkeys.every(isMuted);
return (
<div className="main-content">
<div className="flex mt10">
<div className="f-grow bold">
<FormattedMessage {...messages.MuteCount} values={{ n: pubkeys?.length }} />
</div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent"
type="button"
onClick={() => muteAll(pubkeys)}>
<FormattedMessage {...messages.MuteAll} />
</button>
</div>
{pubkeys?.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
})}
</div>
);
}

View File

@ -0,0 +1,47 @@
.nip05 {
color: var(--font-secondary-color);
justify-content: flex-start;
align-items: center;
font-weight: normal;
}
.nip05 .domain {
color: var(--font-secondary-color);
background-color: var(--font-secondary-color);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}
.nip05 .domain[data-domain="snort.social"] {
background-image: var(--snort-gradient);
}
.nip05 .domain[data-domain="strike.army"] {
background-image: var(--strike-army-gradient);
}
.nip05 .domain[data-domain="nostrplebs.com"] {
color: var(--highlight);
background-color: var(--highlight);
}
.nip05 .domain[data-domain="nostrpurple.com"] {
color: var(--highlight);
background-color: var(--highlight);
}
.nip05 .domain[data-domain="nostr.fan"] {
color: var(--highlight);
background-color: var(--highlight);
}
.nip05 .domain[data-domain="nostriches.net"] {
color: var(--highlight);
background-color: var(--highlight);
}
.nip05 .badge {
margin: 0.1em 0.2em;
}

View File

@ -0,0 +1,75 @@
import { useQuery } from "react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import "./Nip05.css";
import { HexKey } from "@snort/nostr";
interface NostrJson {
names: Record<string, string>;
}
async function fetchNip05Pubkey(name: string, domain: string) {
if (!name || !domain) {
return undefined;
}
try {
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
const data: NostrJson = await res.json();
const match = Object.keys(data.names).find(n => {
return n.toLowerCase() === name.toLowerCase();
});
return match ? data.names[match] : undefined;
} catch (error) {
return undefined;
}
}
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000;
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000;
export function useIsVerified(pubkey: HexKey, nip05?: string, bypassCheck?: boolean) {
const [name, domain] = nip05 ? nip05.split("@") : [];
const { isError, isSuccess, data } = useQuery(
["nip05", nip05],
() => (bypassCheck ? Promise.resolve(pubkey) : fetchNip05Pubkey(name, domain)),
{
retry: false,
retryOnMount: false,
cacheTime: VERIFICATION_CACHE_TIME,
staleTime: VERIFICATION_STALE_TIMEOUT,
}
);
const isVerified = isSuccess && data === pubkey;
const cantVerify = isSuccess && data !== pubkey;
return { isVerified, couldNotVerify: isError || cantVerify };
}
export interface Nip05Params {
nip05?: string;
pubkey: HexKey;
verifyNip?: boolean;
}
const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => {
const [name, domain] = nip05 ? nip05.split("@") : [];
const isDefaultUser = name === "_";
const { isVerified, couldNotVerify } = useIsVerified(pubkey, nip05, !verifyNip);
return (
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={ev => ev.stopPropagation()}>
{!isDefaultUser && <div className="nick">{`${name}@`}</div>}
<span className="domain" data-domain={domain?.toLowerCase()}>
{domain}
</span>
<span className="badge">
{isVerified && <FontAwesomeIcon color={"var(--highlight)"} icon={faCircleCheck} size="xs" />}
{!isVerified && !couldNotVerify && <FontAwesomeIcon color={"var(--fg-color)"} icon={faSpinner} size="xs" />}
{couldNotVerify && <FontAwesomeIcon color={"var(--error)"} icon={faTriangleExclamation} size="xs" />}
</span>
</div>
);
};
export default Nip05;

View File

@ -0,0 +1,304 @@
import { useEffect, useMemo, useState, ChangeEvent } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { unwrap } from "Util";
import { formatShort } from "Number";
import {
ServiceProvider,
ServiceConfig,
ServiceError,
HandleAvailability,
ServiceErrorCode,
HandleRegisterResponse,
CheckRegisterResponse,
} from "Nip05/ServiceProvider";
import AsyncButton from "Element/AsyncButton";
import SendSats from "Element/SendSats";
import Copy from "Element/Copy";
import { useUserProfile } from "Feed/ProfileFeed";
import useEventPublisher from "Feed/EventPublisher";
import { debounce } from "Util";
import { UserMetadata } from "@snort/nostr";
import messages from "./messages";
import { RootState } from "State/Store";
type Nip05ServiceProps = {
name: string;
service: URL | string;
about: JSX.Element;
link: string;
supportLink: string;
helpText?: boolean;
onChange?(h: string): void;
onSuccess?(h: string): void;
};
export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate();
const { helpText = true } = props;
const { formatMessage } = useIntl();
const pubkey = useSelector((s: RootState) => s.login.publicKey);
const user = useUserProfile(pubkey);
const publisher = useEventPublisher();
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
const [error, setError] = useState<ServiceError>();
const [handle, setHandle] = useState<string>("");
const [domain, setDomain] = useState<string>("");
const [checking, setChecking] = useState(false);
const [availabilityResponse, setAvailabilityResponse] = useState<HandleAvailability>();
const [registerResponse, setRegisterResponse] = useState<HandleRegisterResponse>();
const [showInvoice, setShowInvoice] = useState<boolean>(false);
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
const onHandleChange = (e: ChangeEvent<HTMLInputElement>) => {
const h = e.target.value.toLowerCase();
setHandle(h);
if (props.onChange) {
props.onChange(`${h}@${domain}`);
}
};
const onDomainChange = (e: ChangeEvent<HTMLSelectElement>) => {
const d = e.target.value;
setDomain(d);
if (props.onChange) {
props.onChange(`${handle}@${d}`);
}
};
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain, serviceConfig]);
useEffect(() => {
svc
.GetConfig()
.then(a => {
if ("error" in a) {
setError(a as ServiceError);
} else {
const svc = a as ServiceConfig;
setServiceConfig(svc);
const defaultDomain = svc.domains.find(a => a.default)?.name || svc.domains[0].name;
setDomain(defaultDomain);
}
})
.catch(console.error);
}, [props, svc]);
useEffect(() => {
setError(undefined);
setAvailabilityResponse(undefined);
if (handle && domain) {
if (handle.length < (domainConfig?.length[0] ?? 2)) {
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
return;
}
if (handle.length > (domainConfig?.length[1] ?? 20)) {
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
return;
}
const rx = new RegExp(domainConfig?.regex[0] ?? "", domainConfig?.regex[1] ?? "");
if (!rx.test(handle)) {
setAvailabilityResponse({ available: false, why: "REGEX" });
return;
}
return debounce(500, () => {
svc
.CheckAvailable(handle, domain)
.then(a => {
if ("error" in a) {
setError(a as ServiceError);
} else {
setAvailabilityResponse(a as HandleAvailability);
}
})
.catch(console.error);
});
}
}, [handle, domain, domainConfig, svc]);
async function checkRegistration(rsp: HandleRegisterResponse) {
const status = await svc.CheckRegistration(rsp.token);
if ("error" in status) {
setError(status);
setRegisterResponse(undefined);
setShowInvoice(false);
} else {
const result: CheckRegisterResponse = status;
if (result.paid) {
if (!result.available) {
setError({
error: "REGISTERED",
} as ServiceError);
} else {
setError(undefined);
}
setShowInvoice(false);
setRegisterStatus(status);
setRegisterResponse(undefined);
}
}
}
useEffect(() => {
if (registerResponse && showInvoice && !checking) {
const t = setInterval(() => {
if (!checking) {
setChecking(true);
checkRegistration(registerResponse)
.then(() => setChecking(false))
.catch(e => {
console.error(e);
setChecking(false);
});
}
}, 2_000);
return () => clearInterval(t);
}
}, [registerResponse, showInvoice, svc, checking]);
function mapError(e: ServiceErrorCode | undefined, t: string | null): string | undefined {
if (e === undefined) {
return undefined;
}
const whyMap = new Map([
["TOO_SHORT", formatMessage(messages.TooShort)],
["TOO_LONG", formatMessage(messages.TooLong)],
["REGEX", formatMessage(messages.Regex)],
["REGISTERED", formatMessage(messages.Registered)],
["DISALLOWED_null", formatMessage(messages.Disallowed)],
["DISALLOWED_later", formatMessage(messages.DisalledLater)],
]);
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
}
async function startBuy(handle: string, domain: string) {
if (!pubkey) {
return;
}
const rsp = await svc.RegisterHandle(handle, domain, pubkey);
if ("error" in rsp) {
setError(rsp);
} else {
setRegisterResponse(rsp);
setShowInvoice(true);
}
}
async function updateProfile(handle: string, domain: string) {
if (user) {
const nip05 = `${handle}@${domain}`;
const newProfile = {
...user,
nip05,
} as UserMetadata;
const ev = await publisher.metadata(newProfile);
publisher.broadcast(ev);
if (props.onSuccess) {
props.onSuccess(nip05);
}
if (helpText) {
navigate("/settings");
}
}
}
return (
<>
{helpText && <h3>{props.name}</h3>}
{helpText && props.about}
{helpText && (
<p>
<FormattedMessage
{...messages.FindMore}
values={{
service: props.name,
link: (
<a href={props.link} target="_blank" rel="noreferrer">
{props.link}
</a>
),
}}
/>
</p>
)}
{error && <b className="error">{error.error}</b>}
{!registerStatus && (
<div className="flex mb10">
<input type="text" placeholder={formatMessage(messages.Handle)} value={handle} onChange={onHandleChange} />
&nbsp;@&nbsp;
<select value={domain} onChange={onDomainChange}>
{serviceConfig?.domains.map(a => (
<option key={a.name}>{a.name}</option>
))}
</select>
</div>
)}
{availabilityResponse?.available && !registerStatus && (
<div className="flex">
<div className="mr10">
<FormattedMessage
{...messages.Sats}
values={{ n: formatShort(unwrap(availabilityResponse.quote?.price)) }}
/>
<br />
<small>{availabilityResponse.quote?.data.type}</small>
</div>
<AsyncButton onClick={() => startBuy(handle, domain)}>
<FormattedMessage {...messages.BuyNow} />
</AsyncButton>
</div>
)}
{availabilityResponse?.available === false && !registerStatus && (
<div className="flex">
<b className="error">
<FormattedMessage {...messages.NotAvailable} />{" "}
{mapError(availabilityResponse.why, availabilityResponse.reasonTag || null)}
</b>
</div>
)}
<SendSats
invoice={registerResponse?.invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
title={formatMessage(messages.Buying, { item: `${handle}@${domain}` })}
/>
{registerStatus?.paid && (
<div className="flex f-col">
<h4>
<FormattedMessage {...messages.OrderPaid} />
</h4>
<p>
<FormattedMessage {...messages.NewNip} />{" "}
<code>
{handle}@{domain}
</code>
</p>
<h3>
<FormattedMessage {...messages.AccountSupport} />
</h3>
<p>
<FormattedMessage {...messages.SavePassword} />
</p>
<Copy text={registerStatus.password} />
<p>
<FormattedMessage {...messages.GoTo} />{" "}
<a href={props.supportLink} target="_blank" rel="noreferrer">
<FormattedMessage {...messages.AccountPage} />
</a>
</p>
<h4>
<FormattedMessage {...messages.ActivateNow} />
</h4>
<AsyncButton onClick={() => updateProfile(handle, domain)}>
<FormattedMessage {...messages.AddToProfile} />
</AsyncButton>
</div>
)}
</>
);
}

View File

@ -0,0 +1,198 @@
.note {
min-height: 110px;
}
.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;
}
.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 > .body {
margin-top: 4px;
margin-bottom: 24px;
padding-left: 56px;
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: normal;
overflow-x: hidden;
overflow-y: visible;
}
.note > .footer {
padding-left: 46px;
}
.note .footer .footer-reactions {
display: flex;
flex-direction: row;
margin-left: auto;
}
@media (min-width: 720px) {
.note .footer .footer-reactions {
margin-left: 0;
}
}
.note > .footer .ctx-menu {
color: var(--font-secondary-color);
background: transparent;
box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.4);
min-width: 0;
margin: 0;
padding: 0;
border-radius: 16px;
}
.light .note > .footer .ctx-menu {
}
.note > .footer .ctx-menu li {
background: #1e1e1e;
padding-top: 8px;
padding-bottom: 8px;
display: grid;
grid-template-columns: 2rem auto;
}
.light .note > .footer .ctx-menu li {
background: var(--note-bg);
}
.note > .footer .ctx-menu li:first-of-type {
padding-top: 12px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.note > .footer .ctx-menu li:last-of-type {
padding-bottom: 12px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.light .note > .footer .ctx-menu li:hover {
color: white;
background: #2a2a2a;
}
.note > .footer .ctx-menu li:hover {
color: white;
background: var(--font-secondary-color);
}
.ctx-menu .red {
color: var(--error);
}
.note > .header img:hover,
.note > .header .name > .reply:hover,
.note .body:hover {
cursor: pointer;
}
.note > .note-creator {
margin-top: 12px;
margin-left: 56px;
}
.reaction-pill {
display: flex;
flex-direction: row;
margin: 0px 14px;
user-select: none;
color: var(--font-secondary-color);
font-feature-settings: "tnum";
}
.reaction-pill .reaction-pill-number {
margin-left: 8px;
}
.reaction-pill.reacted {
color: var(--highlight);
}
.reaction-pill:hover {
cursor: pointer;
}
.trash-icon {
color: var(--error);
margin-right: auto;
}
.note-expand .body {
max-height: 300px;
overflow-y: hidden;
}
.hidden-note .header {
display: flex;
align-items: center;
}
.card.note.hidden-note {
min-height: unset;
}
.hidden-note button {
max-height: 30px;
}
.expand-note {
padding: 0 0 16px 0;
font-weight: 400;
color: var(--highlight);
cursor: pointer;
}
.note.active {
border-left: 1px solid var(--highlight);
border-bottom-left-radius: 0;
margin-left: -1px;
}

View File

@ -0,0 +1,268 @@
import "./Note.css";
import React, { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";
import { useInView } from "react-intersection-observer";
import { useIntl, FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher";
import Bookmark from "Icons/Bookmark";
import Pin from "Icons/Pin";
import { Event as NEvent, EventKind } from "@snort/nostr";
import ProfileImage from "Element/ProfileImage";
import Text from "Element/Text";
import { eventLink, getReactions, hexToBech32 } from "Util";
import NoteFooter, { Translation } from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import { useUserProfiles } from "Feed/ProfileFeed";
import { TaggedRawEvent, u256, HexKey } from "@snort/nostr";
import useModeration from "Hooks/useModeration";
import { setPinned, setBookmarked } from "State/Login";
import type { RootState } from "State/Store";
import messages from "./messages";
export interface NoteProps {
data?: TaggedRawEvent;
className?: string;
related: TaggedRawEvent[];
highlight?: boolean;
ignoreModeration?: boolean;
options?: {
showHeader?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
showFooter?: boolean;
canUnpin?: boolean;
canUnbookmark?: boolean;
};
["data-ev"]?: NEvent;
}
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 {...messages.MutedAuthor} />
</p>
<button onClick={() => setShow(true)}>
<FormattedMessage {...messages.Show} />
</button>
</div>
</div>
);
};
export default function Note(props: NoteProps) {
const navigate = useNavigate();
const dispatch = useDispatch();
const { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props;
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useUserProfiles(pubKeys);
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
const { isMuted } = useModeration();
const isOpMuted = isMuted(ev.PubKey);
const { ref, inView, entry } = useInView({ triggerOnce: true });
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
const baseClassName = `note card ${props.className ? props.className : ""}`;
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
const publisher = useEventPublisher();
const [translated, setTranslated] = useState<Translation>();
const { formatMessage } = useIntl();
const options = {
showHeader: true,
showTime: true,
showFooter: true,
canUnpin: false,
canUnbookmark: false,
...opt,
};
async function unpin(id: HexKey) {
if (options.canUnpin) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.filter(e => e !== id);
const ev = await publisher.pinned(es);
publisher.broadcast(ev);
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
}
}
}
async function unbookmark(id: HexKey) {
if (options.canUnbookmark) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.filter(e => e !== id);
const ev = await publisher.bookmarked(es);
publisher.broadcast(ev);
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
}
}
}
const transformBody = useCallback(() => {
const body = ev?.Content ?? "";
if (deletions?.length > 0) {
return (
<b className="error">
<FormattedMessage {...messages.Deleted} />
</b>
);
}
return <Text content={body} tags={ev.Tags} users={users || new Map()} creator={ev.PubKey} />;
}, [ev]);
useLayoutEffect(() => {
if (entry && inView && extendable === false) {
const h = entry?.target.clientHeight ?? 0;
if (h > 650) {
setExtendable(true);
}
}
}, [inView, entry, extendable]);
function goToEvent(e: React.MouseEvent, id: u256) {
e.stopPropagation();
navigate(eventLink(id));
}
function replyTag() {
if (ev.Thread === null) {
return null;
}
const maxMentions = 2;
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of ev.Thread?.PubKeys ?? []) {
const u = users?.get(pk);
const npub = hexToBech32("npub", pk);
const shortNpub = npub.substring(0, 12);
if (u) {
mentions.push({
pk,
name: u.name ?? shortNpub,
link: <Link to={`/p/${npub}`}>{u.name ? `@${u.name}` : shortNpub}</Link>,
});
} else {
mentions.push({
pk,
name: shortNpub,
link: <Link to={`/p/${npub}`}>{shortNpub}</Link>,
});
}
}
mentions.sort(a => (a.name.startsWith("npub") ? 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 }) : "";
return (
<div className="reply">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions}
{others}
</>
) : (
replyId && <Link to={eventLink(replyId)}>{hexToBech32("note", replyId)?.substring(0, 12)}</Link>
)}
</div>
);
}
if (ev.Kind !== EventKind.TextNote) {
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.Kind }} />
</h4>
<pre>{JSON.stringify(ev.ToObject(), undefined, " ")}</pre>
</>
);
}
function translation() {
if (translated && translated.confidence > 0.5) {
return (
<>
<p className="highlight">
<FormattedMessage {...messages.TranslatedFrom} values={{ lang: translated.fromLanguage }} />
</p>
{translated.text}
</>
);
} else if (translated) {
return (
<p className="highlight">
<FormattedMessage {...messages.TranslationFailed} />
</p>
);
}
}
function content() {
if (!inView) return null;
return (
<>
{options.showHeader && (
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
{(options.showTime || options.showBookmarked) && (
<div className="info">
{options.showBookmarked && (
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.Id)}>
<Bookmark /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.CreatedAt * 1000} />}
</div>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.Id)}>
<Pin /> <FormattedMessage {...messages.Pinned} />
</div>
)}
</div>
)}
<div className="body" onClick={e => goToEvent(e, ev.Id)}>
{transformBody()}
{translation()}
</div>
{extendable && !showMore && (
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
<FormattedMessage {...messages.ShowMore} />
</span>
)}
{options.showFooter && <NoteFooter ev={ev} related={related} onTranslated={t => setTranslated(t)} />}
</>
);
}
const note = (
<div
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
ref={ref}>
{content()}
</div>
);
return !ignoreModeration && isOpMuted ? <HiddenNote>{note}</HiddenNote> : note;
}

View File

@ -0,0 +1,149 @@
.note-creator {
margin-bottom: 10px;
background-color: var(--note-bg);
border: none;
border-radius: 10px;
padding: 6px;
position: relative;
}
.note-reply {
margin: 10px;
}
.note-creator textarea {
border: none;
outline: none;
resize: none;
background-color: var(--note-bg);
border-radius: 10px 10px 0 0;
min-height: 120px;
max-width: stretch;
min-width: stretch;
}
.note-creator textarea::placeholder {
color: var(--font-secondary-color);
font-size: var(--font-size);
line-height: 24px;
}
@media (min-width: 520px) {
.note-creator textarea {
min-height: 210px;
}
}
@media (min-width: 720px) {
.note-creator textarea {
min-height: 321px;
}
}
.note-creator-actions {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-bottom: 5px;
}
.note-creator .attachment {
cursor: pointer;
position: absolute;
right: 16px;
bottom: 12px;
width: 48px;
height: 36px;
background: var(--gray-dark);
color: white;
border-radius: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.note-creator .attachment:hover {
background: var(--font-color);
color: var(--gray-dark);
}
.light .note-creator .attachment {
background: var(--gray-light);
}
.light .note-creator .attachment:hover {
background: var(--gray-dark);
color: white;
}
.note-creator-actions button:not(:last-child) {
margin-right: 4px;
}
.note-creator .error {
position: absolute;
left: 16px;
bottom: 12px;
font-color: var(--error);
margin-right: 12px;
font-size: 16px;
}
.note-creator .btn {
border-radius: 20px;
font-weight: bold;
background-color: var(--bg-color);
color: var(--font-color);
font-size: var(--font-size);
}
.note-create-button {
width: 48px;
height: 48px;
background-color: var(--highlight);
border: none;
border-radius: 100%;
position: fixed;
bottom: 50px;
right: 16px;
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 520px) {
.note-create-button {
right: 10vw;
}
}
@media (min-width: 1020px) {
.note-create-button {
right: calc(50% - 360px);
}
}
.note-creator-modal .modal-body {
background: var(--modal-bg-color);
}
@media (max-width: 720px) {
.note-creator-modal {
align-items: flex-start;
}
.note-creator-modal .modal-body {
margin-top: 20vh;
}
}
.note-preview {
word-break: break-all;
}
.note-preview-body {
text-overflow: ellipsis;
padding: 4px 4px 0 56px;
font-size: 14px;
}

View File

@ -0,0 +1,133 @@
import "./NoteCreator.css";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import Attachment from "Icons/Attachment";
import useEventPublisher from "Feed/EventPublisher";
import { openFile } from "Util";
import Textarea from "Element/Textarea";
import Modal from "Element/Modal";
import ProfileImage from "Element/ProfileImage";
import { Event as NEvent } from "@snort/nostr";
import useFileUpload from "Upload";
import messages from "./messages";
interface NotePreviewProps {
note: NEvent;
}
function NotePreview({ note }: NotePreviewProps) {
return (
<div className="note-preview">
<ProfileImage pubkey={note.PubKey} />
<div className="note-preview-body">
{note.Content.slice(0, 136)}
{note.Content.length > 140 && "..."}
</div>
</div>
);
}
export interface NoteCreatorProps {
show: boolean;
setShow: (s: boolean) => void;
replyTo?: NEvent;
onSend?: () => void;
autoFocus: boolean;
}
export function NoteCreator(props: NoteCreatorProps) {
const { show, setShow, replyTo, onSend, autoFocus } = props;
const publisher = useEventPublisher();
const [note, setNote] = useState<string>("");
const [error, setError] = useState<string>();
const [active, setActive] = useState<boolean>(false);
const uploader = useFileUpload();
const hasErrors = (error?.length ?? 0) > 0;
async function sendNote() {
if (note) {
const ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
console.debug("Sending note: ", ev);
publisher.broadcast(ev);
setNote("");
setShow(false);
if (typeof onSend === "function") {
onSend();
}
setActive(false);
}
}
async function attachFile() {
try {
const file = await openFile();
if (file) {
const rx = await uploader.upload(file, file.name);
if (rx.url) {
setNote(n => `${n ? `${n}\n` : ""}${rx.url}`);
} else if (rx?.error) {
setError(rx.error);
}
}
} catch (error: unknown) {
if (error instanceof Error) {
setError(error?.message);
}
}
}
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target;
setNote(value);
if (value) {
setActive(true);
} else {
setActive(false);
}
}
function cancel() {
setShow(false);
setNote("");
}
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
ev.stopPropagation();
sendNote().catch(console.warn);
}
return (
<>
{show && (
<Modal className="note-creator-modal" onClose={() => setShow(false)}>
{replyTo && <NotePreview note={replyTo} />}
<div className={`flex note-creator ${replyTo ? "note-reply" : ""}`}>
<div className="flex f-col mr10 f-grow">
<Textarea
autoFocus={autoFocus}
className={`textarea ${active ? "textarea--focused" : ""}`}
onChange={onChange}
value={note}
onFocus={() => setActive(true)}
/>
<button type="button" className="attachment" onClick={attachFile}>
<Attachment />
</button>
</div>
{hasErrors && <span className="error">{error}</span>}
</div>
<div className="note-creator-actions">
<button className="secondary" type="button" onClick={cancel}>
<FormattedMessage {...messages.Cancel} />
</button>
<button type="button" onClick={onSubmit}>
{replyTo ? <FormattedMessage {...messages.Reply} /> : <FormattedMessage {...messages.Send} />}
</button>
</div>
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,345 @@
import { useMemo, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useIntl, FormattedMessage } from "react-intl";
import { Menu, MenuItem } from "@szhsin/react-menu";
import Bookmark from "Icons/Bookmark";
import Pin from "Icons/Pin";
import Json from "Icons/Json";
import Repost from "Icons/Repost";
import Trash from "Icons/Trash";
import Translate from "Icons/Translate";
import Block from "Icons/Block";
import Mute from "Icons/Mute";
import Share from "Icons/Share";
import Copy from "Icons/Copy";
import Dislike from "Icons/Dislike";
import Heart from "Icons/Heart";
import Dots from "Icons/Dots";
import Zap from "Icons/Zap";
import Reply from "Icons/Reply";
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
import { getReactions, dedupeByPubkey, hexToBech32, normalizeReaction, Reaction } from "Util";
import { NoteCreator } from "Element/NoteCreator";
import Reactions from "Element/Reactions";
import SendSats from "Element/SendSats";
import { parseZap, ZapsSummary } from "Element/Zap";
import { useUserProfile } from "Feed/ProfileFeed";
import { Event as NEvent, EventKind, TaggedRawEvent } from "@snort/nostr";
import { RootState } from "State/Store";
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
import useModeration from "Hooks/useModeration";
import { TranslateHost } from "Const";
import messages from "./messages";
export interface Translation {
text: string;
fromLanguage: string;
confidence: number;
}
export interface NoteFooterProps {
related: TaggedRawEvent[];
ev: NEvent;
onTranslated?: (content: Translation) => void;
}
export default function NoteFooter(props: NoteFooterProps) {
const { related, ev } = props;
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useUserProfile(ev.RootPubKey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login;
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
const reposts = useMemo(() => dedupeByPubkey(getReactions(related, ev.Id, EventKind.Repost)), [related, ev]);
const zaps = useMemo(() => {
const sortedZaps = getReactions(related, ev.Id, EventKind.ZapReceipt)
.map(parseZap)
.filter(z => z.valid && z.zapper !== ev.PubKey);
sortedZaps.sort((a, b) => b.amount - a.amount);
return sortedZaps;
}, [related]);
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = zaps.some(a => a.zapper === login);
const groupReactions = useMemo(() => {
const result = reactions?.reduce(
(acc, reaction) => {
const kind = normalizeReaction(reaction.content);
const rs = acc[kind] || [];
if (rs.map(e => e.pubkey).includes(reaction.pubkey)) {
return acc;
}
return { ...acc, [kind]: [...rs, reaction] };
},
{
[Reaction.Positive]: [] as TaggedRawEvent[],
[Reaction.Negative]: [] as TaggedRawEvent[],
}
);
return {
[Reaction.Positive]: dedupeByPubkey(result[Reaction.Positive]),
[Reaction.Negative]: dedupeByPubkey(result[Reaction.Negative]),
};
}, [reactions]);
const positive = groupReactions[Reaction.Positive];
const negative = groupReactions[Reaction.Negative];
function hasReacted(emoji: string) {
return reactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
}
function hasReposted() {
return reposts.some(a => a.pubkey === login);
}
async function react(content: string) {
if (!hasReacted(content)) {
const evLike = await publisher.react(ev, content);
publisher.broadcast(evLike);
}
}
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.Id.substring(0, 8) }))) {
const evDelete = await publisher.delete(ev.Id);
publisher.broadcast(evDelete);
}
}
async function repost() {
if (!hasReposted()) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.Id }))) {
const evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
}
}
}
function tipButton() {
const service = author?.lud16 || author?.lud06;
if (service) {
return (
<>
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} onClick={() => setTip(true)}>
<div className="reaction-pill-icon">
<Zap />
</div>
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
</div>
</>
);
}
return null;
}
function repostIcon() {
return (
<div className={`reaction-pill ${hasReposted() ? "reacted" : ""}`} onClick={() => repost()}>
<div className="reaction-pill-icon">
<Repost width={18} height={16} />
</div>
{reposts.length > 0 && <div className="reaction-pill-number">{formatShort(reposts.length)}</div>}
</div>
);
}
function reactionIcons() {
if (!prefs.enableReactions) {
return null;
}
return (
<>
<div
className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `}
onClick={() => react(prefs.reactionEmoji)}>
<div className="reaction-pill-icon">
<Heart />
</div>
<div className="reaction-pill-number">{formatShort(positive.length)}</div>
</div>
{repostIcon()}
</>
);
}
async function share() {
const url = `${window.location.protocol}//${window.location.host}/e/${hexToBech32("note", ev.Id)}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const res = await fetch(`${TranslateHost}/translate`, {
method: "POST",
body: JSON.stringify({
q: ev.Content,
source: "auto",
target: lang.split("-")[0],
}),
headers: { "Content-Type": "application/json" },
});
if (res.ok) {
const result = await res.json();
if (typeof props.onTranslated === "function" && result) {
props.onTranslated({
text: result.translatedText,
fromLanguage: langNames.of(result.detectedLanguage.language),
confidence: result.detectedLanguage.confidence,
} as Translation);
}
}
}
async function copyId() {
await navigator.clipboard.writeText(hexToBech32("note", ev.Id));
}
async function pin(id: HexKey) {
const es = [...pinned, id];
const ev = await publisher.pinned(es);
publisher.broadcast(ev);
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
}
async function bookmark(id: HexKey) {
const es = [...bookmarked, id];
const ev = await publisher.bookmarked(es);
publisher.broadcast(ev);
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, " "));
}
function menuItems() {
return (
<>
{prefs.enableReactions && (
<MenuItem onClick={() => setShowReactions(true)}>
<Heart />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
)}
<MenuItem onClick={() => share()}>
<Share />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!pinned.includes(ev.Id) && (
<MenuItem onClick={() => pin(ev.Id)}>
<Pin />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!bookmarked.includes(ev.Id) && (
<MenuItem onClick={() => bookmark(ev.Id)}>
<Bookmark width={18} height={18} />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Copy />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
<MenuItem onClick={() => mute(ev.PubKey)}>
<Mute />
<FormattedMessage {...messages.Mute} />
</MenuItem>
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
<Dislike />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
<MenuItem onClick={() => block(ev.PubKey)}>
<Block />
<FormattedMessage {...messages.Block} />
</MenuItem>
<MenuItem onClick={() => translate()}>
<Translate />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
</MenuItem>
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
<Json />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
)}
{isMine && (
<MenuItem onClick={() => deleteEvent()}>
<Trash className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
);
}
return (
<>
<div className="footer">
<div className="footer-reactions">
{tipButton()}
{reactionIcons()}
<div className={`reaction-pill ${reply ? "reacted" : ""}`} onClick={() => setReply(s => !s)}>
<div className="reaction-pill-icon">
<Reply />
</div>
</div>
<Menu
menuButton={
<div className="reaction-pill">
<div className="reaction-pill-icon">
<Dots />
</div>
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
</div>
<NoteCreator autoFocus={true} replyTo={ev} onSend={() => setReply(false)} show={reply} setShow={setReply} />
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={positive}
negative={negative}
reposts={reposts}
zaps={zaps}
/>
<SendSats
svc={author?.lud16 || author?.lud06}
onClose={() => setTip(false)}
show={tip}
author={author?.pubkey}
target={author?.display_name || author?.name}
note={ev.Id}
/>
</div>
<div className="zaps-container">
<ZapsSummary zaps={zaps} />
</div>
</>
);
}

View File

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

View File

@ -0,0 +1,23 @@
.reaction {
}
.reaction > .note {
margin: 10px 0;
}
.reaction > .header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.reaction > .header .reply {
font-size: var(--font-size-small);
}
.reaction > .header > .info {
font-size: var(--font-size);
white-space: nowrap;
color: var(--font-secondary-color);
margin-right: 24px;
}

View File

@ -0,0 +1,76 @@
import "./NoteReaction.css";
import { Link } from "react-router-dom";
import { useMemo } from "react";
import { EventKind, Event as NEvent } from "@snort/nostr";
import Note from "Element/Note";
import ProfileImage from "Element/ProfileImage";
import { eventLink, hexToBech32 } from "Util";
import NoteTime from "Element/NoteTime";
import { RawEvent, TaggedRawEvent } from "@snort/nostr";
import useModeration from "Hooks/useModeration";
export interface NoteReactionProps {
data?: TaggedRawEvent;
["data-ev"]?: NEvent;
root?: TaggedRawEvent;
}
export default function NoteReaction(props: NoteReactionProps) {
const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]);
const { isMuted } = useModeration();
const refEvent = useMemo(() => {
if (ev) {
const eTags = ev.Tags.filter(a => a.Key === "e");
if (eTags.length > 0) {
return eTags[0].Event;
}
}
return null;
}, [ev]);
if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
return null;
}
/**
* Some clients embed the reposted note in the content
*/
function extractRoot() {
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0 && ev.Content !== "#[0]") {
try {
const r: RawEvent = JSON.parse(ev.Content);
return r as TaggedRawEvent;
} catch (e) {
console.error("Could not load reposted content", e);
}
}
return props.root;
}
const root = extractRoot();
const isOpMuted = root && isMuted(root.pubkey);
const opt = {
showHeader: ev?.Kind === EventKind.Repost,
showFooter: false,
};
return isOpMuted ? null : (
<div className="reaction">
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} />
<div className="info">
<NoteTime from={ev.CreatedAt * 1000} />
</div>
</div>
{root ? <Note data={root} options={opt} related={[]} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link>
</p>
) : null}
</div>
);
}

View File

@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
const MinuteInMs = 1_000 * 60;
const HourInMs = MinuteInMs * 60;
const DayInMs = HourInMs * 24;
export interface NoteTimeProps {
from: number;
fallback?: string;
}
export default function NoteTime(props: NoteTimeProps) {
const [time, setTime] = useState<string>();
const { from, fallback } = props;
const absoluteTime = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from);
const fromDate = new Date(from);
const isoDate = fromDate.toISOString();
function calcTime() {
const fromDate = new Date(from);
const ago = new Date().getTime() - from;
const absAgo = Math.abs(ago);
if (absAgo > DayInMs) {
return fromDate.toLocaleDateString(undefined, {
year: "2-digit",
month: "short",
day: "2-digit",
weekday: "short",
});
} else if (absAgo > HourInMs) {
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate.getMinutes().toString().padStart(2, "0")}`;
} else if (absAgo < MinuteInMs) {
return fallback;
} else {
const mins = Math.floor(absAgo / MinuteInMs);
if (ago < 0) {
return `in ${mins}m`;
}
return `${mins}m`;
}
}
useEffect(() => {
setTime(calcTime());
const t = setInterval(() => {
setTime(s => {
const newTime = calcTime();
if (newTime !== s) {
return newTime;
}
return s;
});
}, MinuteInMs);
return () => clearInterval(t);
}, [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time}
</time>
);
}

View File

@ -0,0 +1,38 @@
.nts {
display: flex;
align-items: center;
}
.note-to-self {
margin-left: 5px;
margin-top: 3px;
}
.nts .avatar-wrapper {
margin-right: 8px;
}
.nts .avatar {
border-width: 1px;
width: 40px;
height: 40px;
}
.nts .avatar.clickable {
cursor: pointer;
}
.nts a {
text-decoration: none;
}
.nts .name {
margin-top: -0.2em;
display: flex;
flex-direction: column;
font-weight: bold;
}
.nts .nip05 {
margin: 0;
margin-top: -0.2em;
}

View File

@ -0,0 +1,56 @@
import "./NoteToSelf.css";
import { Link, useNavigate } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons";
import { useUserProfile } from "Feed/ProfileFeed";
import Nip05 from "Element/Nip05";
import { profileLink } from "Util";
import messages from "./messages";
export interface NoteToSelfProps {
pubkey: string;
clickable?: boolean;
className?: string;
link?: string;
}
function NoteLabel({ pubkey }: NoteToSelfProps) {
const user = useUserProfile(pubkey);
return (
<div>
<FormattedMessage {...messages.NoteToSelf} /> <FontAwesomeIcon icon={faCertificate} size="xs" />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div>
);
}
export default function NoteToSelf({ pubkey, clickable, className, link }: NoteToSelfProps) {
const navigate = useNavigate();
const clickLink = () => {
if (clickable) {
navigate(link ?? profileLink(pubkey));
}
};
return (
<div className={`nts${className ? ` ${className}` : ""}`}>
<div className="avatar-wrapper">
<div className={`avatar${clickable ? " clickable" : ""}`}>
<FontAwesomeIcon onClick={clickLink} className="note-to-self" icon={faBook} size="2xl" />
</div>
</div>
<div className="f-grow">
<div className="name">
{(clickable && (
<Link to={link ?? profileLink(pubkey)}>
<NoteLabel pubkey={pubkey} />
</Link>
)) || <NoteLabel pubkey={pubkey} />}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,32 @@
.pfp {
display: flex;
align-items: center;
overflow: hidden;
}
.pfp .avatar-wrapper {
margin-right: 8px;
z-index: 2;
}
.pfp .avatar {
width: 48px;
height: 48px;
cursor: pointer;
}
.pfp a {
text-decoration: none;
}
.pfp .username {
display: flex;
flex-direction: column;
align-items: flex-start;
font-weight: 600;
}
.pfp .profile-name {
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,71 @@
import "./ProfileImage.css";
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useUserProfile } from "Feed/ProfileFeed";
import { hexToBech32, profileLink } from "Util";
import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { HexKey } from "@snort/nostr";
import { MetadataCache } from "State/Users";
export interface ProfileImageProps {
pubkey: HexKey;
subHeader?: JSX.Element;
showUsername?: boolean;
className?: string;
link?: string;
defaultNip?: string;
verifyNip?: boolean;
}
export default function ProfileImage({
pubkey,
subHeader,
showUsername = true,
className,
link,
defaultNip,
verifyNip,
}: ProfileImageProps) {
const navigate = useNavigate();
const user = useUserProfile(pubkey);
const nip05 = defaultNip ? defaultNip : user?.nip05;
const name = useMemo(() => {
return getDisplayName(user, pubkey);
}, [user, pubkey]);
if (!pubkey && !link) {
link = "#";
}
return (
<div className={`pfp${className ? ` ${className}` : ""}`}>
<div className="avatar-wrapper">
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
</div>
{showUsername && (
<div className="profile-name f-grow">
<div className="username">
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
{name}
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
</Link>
</div>
<div className="subheader">{subHeader}</div>
</div>
)}
</div>
);
}
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
let name = hexToBech32("npub", pubkey).substring(0, 12);
if (user?.display_name !== undefined && user.display_name.length > 0) {
name = user.display_name;
} else if (user?.name !== undefined && user.name.length > 0) {
name = user.name;
}
return name;
}

View File

@ -0,0 +1,15 @@
.profile-preview {
display: flex;
align-items: center;
min-height: 40px;
}
.profile-preview .pfp {
flex-grow: 1;
min-width: 200px;
}
.profile-preview .about {
font-size: small;
color: var(--gray-light);
}

View File

@ -0,0 +1,44 @@
import "./ProfilePreview.css";
import { ReactNode } from "react";
import ProfileImage from "Element/ProfileImage";
import FollowButton from "Element/FollowButton";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "@snort/nostr";
import { useInView } from "react-intersection-observer";
export interface ProfilePreviewProps {
pubkey: HexKey;
options?: {
about?: boolean;
};
actions?: ReactNode;
className?: string;
}
export default function ProfilePreview(props: ProfilePreviewProps) {
const pubkey = props.pubkey;
const user = useUserProfile(pubkey);
const { ref, inView } = useInView({ triggerOnce: true });
const options = {
about: true,
...props.options,
};
return (
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
{inView && (
<>
<ProfileImage
pubkey={pubkey}
subHeader={options.about ? <div className="f-ellipsis about">{user?.about}</div> : undefined}
/>
{props.actions ?? (
<div className="follow-button-container">
<FollowButton pubkey={pubkey} />
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,22 @@
import useImgProxy from "Feed/ImgProxy";
import { useEffect, useState } from "react";
interface ProxyImgProps extends React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement> {
size?: number;
}
export const ProxyImg = (props: ProxyImgProps) => {
const { src, size, ...rest } = props;
const [url, setUrl] = useState<string>();
const { proxy } = useImgProxy();
useEffect(() => {
if (src) {
proxy(src, size)
.then(a => setUrl(a))
.catch(console.warn);
}
}, [src]);
return <img src={url} {...rest} />;
};

View File

@ -0,0 +1,50 @@
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef } from "react";
export interface QrCodeProps {
data?: string;
link?: string;
avatar?: string;
height?: number;
width?: number;
className?: string;
}
export default function QrCode(props: QrCodeProps) {
const qrRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
const qr = new QRCodeStyling({
width: props.width || 256,
height: props.height || 256,
data: props.data,
margin: 5,
type: "canvas",
image: props.avatar,
dotsOptions: {
type: "rounded",
},
cornersSquareOptions: {
type: "extra-rounded",
},
imageOptions: {
crossOrigin: "anonymous",
},
});
qrRef.current.innerHTML = "";
qr.append(qrRef.current);
if (props.link) {
qrRef.current.onclick = function () {
const elm = document.createElement("a");
elm.href = props.link ?? "";
elm.click();
};
}
} else if (qrRef.current) {
qrRef.current.innerHTML = "";
}
}, [props.data, props.link]);
return <div className={`qr${props.className ?? ""}`} ref={qrRef}></div>;
}

View File

@ -0,0 +1,122 @@
.reactions-modal .modal-body {
padding: 0;
max-width: 586px;
}
.reactions-view {
padding: 24px 32px;
background-color: #1b1b1b;
border-radius: 16px;
position: relative;
}
.light .reactions-view {
background-color: var(--note-bg);
}
@media (max-width: 720px) {
.reactions-view {
padding: 12px 16px;
margin-top: -160px;
}
}
.reactions-view .close {
position: absolute;
top: 12px;
right: 16px;
color: var(--font-secondary-color);
cursor: pointer;
}
.reactions-view .close:hover {
color: var(--font-tertiary-color);
}
.reactions-view .reactions-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 32px;
}
.reactions-view .reactions-header h2 {
margin: 0;
flex-grow: 1;
font-weight: 600;
font-size: 16px;
line-height: 19px;
}
.reactions-view .body {
overflow: scroll;
height: 320px;
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* Firefox */
}
.reactions-view .body::-webkit-scrollbar {
display: none;
}
.reactions-item {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 24px;
}
.reactions-item .reaction-icon {
width: 52px;
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-view .tab.disabled {
display: none;
}
.reactions-item .reaction-icon {
width: 42px;
}
.reactions-item .avatar {
width: 21px;
height: 21px;
}
.reactions-item .pfp .username {
font-size: 14px;
}
.reactions-item .pfp .nip05 {
display: none;
}
.reactions-item button {
font-size: 14px;
}
.reactions-item .zap-reaction-icon svg {
width: 12px;
height: l2px;
}
.reactions-item .zap-amount {
font-size: 12px;
}
}

View File

@ -0,0 +1,145 @@
import "./Reactions.css";
import { useState, useMemo, useEffect } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedRawEvent } from "@snort/nostr";
import { formatShort } from "Number";
import Dislike from "Icons/Dislike";
import Heart from "Icons/Heart";
import ZapIcon from "Icons/Zap";
import { Tab } from "Element/Tabs";
import { ParsedZap } from "Element/Zap";
import ProfileImage from "Element/ProfileImage";
import Tabs from "Element/Tabs";
import Close from "Icons/Close";
import Modal from "Element/Modal";
import messages from "./messages";
interface ReactionsProps {
show: boolean;
setShow(b: boolean): void;
positive: TaggedRawEvent[];
negative: TaggedRawEvent[];
reposts: TaggedRawEvent[];
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 className="reactions-modal" onClose={onClose}>
<div className="reactions-view">
<div className="close" onClick={onClose}>
<Close />
</div>
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
<div className="body" key={tab.value}>
{tab.value === 0 &&
likes.map(ev => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
{ev.content === "+" ? <Heart width={20} height={18} /> : ev.content}
</div>
<ProfileImage pubkey={ev.pubkey} />
</div>
);
})}
{tab.value === 1 &&
zaps.map(z => {
return (
z.zapper && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<ZapIcon width={17} height={20} />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage pubkey={z.zapper} subHeader={<>{z.content}</>} />
</div>
)
);
})}
{tab.value === 2 &&
reposts.map(ev => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Heart width={20} height={18} />
</div>
<ProfileImage pubkey={ev.pubkey} />
</div>
);
})}
{tab.value === 3 &&
dislikes.map(ev => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Dislike width={20} height={18} />
</div>
<ProfileImage pubkey={ev.pubkey} />
</div>
);
})}
</div>
</div>
</Modal>
) : null;
};
export default Reactions;

View File

@ -0,0 +1,42 @@
.relay {
margin-top: 10px;
background-color: var(--gray-secondary);
border-radius: 5px;
text-align: start;
display: grid;
grid-template-columns: min-content auto;
overflow: hidden;
font-size: var(--font-size-small);
}
.relay > div {
padding: 5px;
}
.relay-extra {
padding: 5px;
margin: 0 5px;
background-color: var(--gray-tertiary);
border-radius: 0 0 5px 5px;
white-space: nowrap;
font-size: var(--font-size-small);
}
.icon-btn {
padding: 2px 10px;
border-radius: 10px;
background-color: var(--gray);
user-select: none;
color: var(--font-color);
}
.icon-btn:hover {
cursor: pointer;
}
.checkmark {
margin-left: 0.5em;
padding: 2px 10px;
background-color: var(--gray);
border-radius: 10px;
}

View File

@ -0,0 +1,107 @@
import "./Relay.css";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import {
faPlug,
faSquareCheck,
faSquareXmark,
faWifi,
faPlugCircleXmark,
faGear,
} from "@fortawesome/free-solid-svg-icons";
import useRelayState from "Feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setRelays } from "State/Login";
import { RootState } from "State/Store";
import { RelaySettings } from "@snort/nostr";
import messages from "./messages";
export interface RelayProps {
addr: string;
}
export default function Relay(props: RelayProps) {
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = allRelaySettings[props.addr];
const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
function configure(o: RelaySettings) {
dispatch(
setRelays({
relays: {
...allRelaySettings,
[props.addr]: o,
},
createdAt: Math.floor(new Date().getTime() / 1000),
})
);
}
const latency = Math.floor(state?.avgLatency ?? 0);
return (
<>
<div className={`relay w-max`}>
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
<FontAwesomeIcon icon={faPlug} />
</div>
<div className="f-grow f-col">
<div className="flex mb10">
<b className="f-2">{name}</b>
<div className="f-1">
<FormattedMessage {...messages.Write} />
<span
className="checkmark"
onClick={() =>
configure({
write: !relaySettings.write,
read: relaySettings.read,
})
}>
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} />
</span>
</div>
<div className="f-1">
<FormattedMessage {...messages.Read} />
<span
className="checkmark"
onClick={() =>
configure({
write: relaySettings.write,
read: !relaySettings.read,
})
}>
<FontAwesomeIcon icon={relaySettings.read ? faSquareCheck : faSquareXmark} />
</span>
</div>
</div>
<div className="flex">
<div className="f-grow">
<FontAwesomeIcon icon={faWifi} />{" "}
{latency > 2000
? formatMessage(messages.Seconds, {
n: (latency / 1000).toFixed(0),
})
: formatMessage(messages.Milliseconds, {
n: latency.toLocaleString(),
})}
&nbsp;
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div>
<div>
<span className="icon-btn" onClick={() => navigate(state?.id ?? "")}>
<FontAwesomeIcon icon={faGear} />
</span>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,36 @@
.favicon {
width: 21px;
height: 21px;
border-radius: 100%;
margin-right: 12px;
}
.relay-card {
display: flex;
flex-direction: row;
align-items: center;
}
.relay-settings {
margin-left: auto;
}
.relay-settings svg:not(:last-child) {
margin-right: 12px;
}
.relay-settings svg.enabled {
color: var(--highlight);
}
.relay-settings svg.disabled {
opacity: 0.3;
}
.relay-url {
font-size: 14px;
}
@media (min-width: 520px) {
.relay-url {
font-size: 16px;
}
}

View File

@ -0,0 +1,44 @@
import "./RelaysMetadata.css";
import Nostrich from "nostrich.webp";
import { useState } from "react";
import { FullRelaySettings } from "@snort/nostr";
import Read from "Icons/Read";
import Write from "Icons/Write";
const RelayFavicon = ({ url }: { url: string }) => {
const cleanUrl = url
.replace("wss://relay.", "https://")
.replace("wss://nostr.", "https://")
.replace("wss://", "https://")
.replace("ws://", "http://")
.replace(/\/$/, "");
const [faviconUrl, setFaviconUrl] = useState(`${cleanUrl}/favicon.ico`);
return <img className="favicon" src={faviconUrl} onError={() => setFaviconUrl(Nostrich)} />;
};
interface RelaysMetadataProps {
relays: FullRelaySettings[];
}
const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
return (
<div className="main-content">
{relays?.map(({ url, settings }) => {
return (
<div className="card relay-card">
<RelayFavicon url={url} />
<code className="relay-url">{url}</code>
<div className="relay-settings">
<Read className={settings.read ? "enabled" : "disabled"} />
<Write className={settings.write ? "enabled" : "disabled"} />
</div>
</div>
);
})}
</div>
);
};
export default RelaysMetadata;

View File

@ -0,0 +1,167 @@
.lnurl-modal .modal-body {
padding: 0;
max-width: 470px;
}
.lnurl-modal .lnurl-tip .pfp .avatar {
width: 48px;
height: 48px;
}
.lnurl-tip {
padding: 24px 32px;
background-color: #1b1b1b;
border-radius: 16px;
position: relative;
}
@media (max-width: 720px) {
.lnurl-tip {
padding: 12px 16px;
}
}
.light .lnurl-tip {
background-color: var(--note-bg);
}
.lnurl-tip h3 {
color: var(--font-secondary-color);
font-size: 11px;
letter-spacing: 0.11em;
font-weight: 600;
line-height: 13px;
text-transform: uppercase;
}
.lnurl-tip .close {
position: absolute;
top: 12px;
right: 16px;
color: var(--font-secondary-color);
cursor: pointer;
}
.lnurl-tip .close:hover {
color: var(--font-tertiary-color);
}
.lnurl-tip .lnurl-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 32px;
}
.lnurl-tip .lnurl-header h2 {
margin: 0;
flex-grow: 1;
font-weight: 600;
font-size: 16px;
line-height: 19px;
}
.amounts {
display: flex;
width: 100%;
overflow-x: scroll;
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* for Firefox */
margin-bottom: 16px;
}
.amounts::-webkit-scrollbar {
display: none;
}
.sat-amount {
text-align: center;
display: inline-block;
background-color: #2a2a2a;
color: var(--font-color);
padding: 12px 16px;
border-radius: 100px;
user-select: none;
font-weight: 600;
font-size: 14px;
line-height: 17px;
}
.light .sat-amount {
background-color: var(--gray);
}
.sat-amount:not(:last-child) {
margin-right: 8px;
}
.sat-amount:hover {
cursor: pointer;
}
.sat-amount.active {
font-weight: bold;
color: var(--note-bg);
background-color: var(--font-color);
}
.lnurl-tip .invoice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.lnurl-tip .invoice .actions {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: center;
}
.lnurl-tip .invoice .actions .copy-action {
margin: 10px auto;
}
.lnurl-tip .invoice .actions .wallet-action {
width: 100%;
height: 40px;
}
.lnurl-tip .zap-action {
margin-top: 16px;
width: 100%;
height: 40px;
}
.lnurl-tip .zap-action svg {
margin-right: 10px;
}
.lnurl-tip .zap-action-container {
display: flex;
align-items: center;
justify-content: center;
}
.lnurl-tip .custom-amount {
margin-bottom: 16px;
}
.lnurl-tip .custom-amount button {
padding: 12px 18px;
border-radius: 100px;
}
.lnurl-tip canvas {
border-radius: 10px;
}
.lnurl-tip .success-action .paid {
font-size: 19px;
}
.lnurl-tip .success-action a {
color: var(--highlight);
font-size: 19px;
}

View File

@ -0,0 +1,319 @@
import "./SendSats.css";
import { useEffect, useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { formatShort } from "Number";
import { bech32ToText } from "Util";
import { HexKey } from "@snort/nostr";
import Check from "Icons/Check";
import Zap from "Icons/Zap";
import Close from "Icons/Close";
import useEventPublisher from "Feed/EventPublisher";
import ProfileImage from "Element/ProfileImage";
import Modal from "Element/Modal";
import QrCode from "Element/QrCode";
import Copy from "Element/Copy";
import useWebln from "Hooks/useWebln";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
import messages from "./messages";
interface LNURLService {
nostrPubkey?: HexKey;
minSendable?: number;
maxSendable?: number;
metadata: string;
callback: string;
commentAllowed?: number;
}
interface LNURLInvoice {
pr: string;
successAction?: LNURLSuccessAction;
}
interface LNURLSuccessAction {
description?: string;
url?: string;
}
export interface LNURLTipProps {
onClose?: () => void;
svc?: string;
show?: boolean;
invoice?: string; // shortcut to invoice qr tab
title?: string;
notice?: string;
target?: string;
note?: HexKey;
author?: HexKey;
}
export default function LNURLTip(props: LNURLTipProps) {
const onClose = props.onClose || (() => undefined);
const service = props.svc;
const show = props.show || false;
const { note, author, target } = props;
const amounts = [500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
const emojis: Record<number, string> = {
1_000: "👍",
5_000: "💜",
10_000: "😍",
20_000: "🤩",
50_000: "🔥",
100_000: "🚀",
1_000_000: "🤯",
};
const [payService, setPayService] = useState<LNURLService>();
const [amount, setAmount] = useState<number>(500);
const [customAmount, setCustomAmount] = useState<number>();
const [invoice, setInvoice] = useState<LNURLInvoice>();
const [comment, setComment] = useState<string>();
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
const webln = useWebln(show);
const { formatMessage } = useIntl();
const publisher = useEventPublisher();
const horizontalScroll = useHorizontalScroll();
const canComment = (payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey;
useEffect(() => {
if (show && !props.invoice) {
loadService()
.then(a => setPayService(a ?? undefined))
.catch(() => setError(formatMessage(messages.LNURLFail)));
} else {
setPayService(undefined);
setError(undefined);
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
setAmount(500);
setComment(undefined);
setSuccess(undefined);
}
}, [show, service]);
const serviceAmounts = useMemo(() => {
if (payService) {
const min = (payService.minSendable ?? 0) / 1000;
const max = (payService.maxSendable ?? 0) / 1000;
return amounts.filter(a => a >= min && a <= max);
}
return [];
}, [payService]);
const selectAmount = (a: number) => {
setError(undefined);
setInvoice(undefined);
setAmount(a);
};
async function fetchJson<T>(url: string) {
const rsp = await fetch(url);
if (rsp.ok) {
const data: T = await rsp.json();
console.log(data);
setError(undefined);
return data;
}
return null;
}
async function loadService(): Promise<LNURLService | null> {
if (service) {
const isServiceUrl = service.toLowerCase().startsWith("lnurl");
if (isServiceUrl) {
const serviceUrl = bech32ToText(service);
return await fetchJson(serviceUrl);
} else {
const ns = service.split("@");
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
}
}
return null;
}
async function loadInvoice() {
if (!amount || !payService) return null;
let url = "";
const amountParam = `amount=${Math.floor(amount * 1000)}`;
const commentParam = comment && payService?.commentAllowed ? `&comment=${encodeURIComponent(comment)}` : "";
if (payService.nostrPubkey && author) {
const ev = await publisher.zap(author, note, comment);
const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`;
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
} else {
url = `${payService.callback}?${amountParam}${commentParam}`;
}
try {
const rsp = await fetch(url);
if (rsp.ok) {
const data = await rsp.json();
console.log(data);
if (data.status === "ERROR") {
setError(data.reason);
} else {
setInvoice(data);
setError("");
payWebLNIfEnabled(data);
}
} else {
setError(formatMessage(messages.InvoiceFail));
}
} catch (e) {
setError(formatMessage(messages.InvoiceFail));
}
}
function custom() {
const min = (payService?.minSendable ?? 1000) / 1000;
const max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
return (
<div className="custom-amount flex">
<input
type="number"
min={min}
max={max}
className="f-grow mr10"
placeholder={formatMessage(messages.Custom)}
value={customAmount}
onChange={e => setCustomAmount(parseInt(e.target.value))}
/>
<button
className="secondary"
type="button"
disabled={!customAmount}
onClick={() => selectAmount(customAmount ?? 0)}>
<FormattedMessage {...messages.Confirm} />
</button>
</div>
);
}
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
try {
if (webln?.enabled) {
const res = await webln.sendPayment(invoice?.pr ?? "");
console.log(res);
setSuccess(invoice?.successAction ?? {});
}
} catch (e: unknown) {
console.warn(e);
if (e instanceof Error) {
setError(e.toString());
}
}
}
function invoiceForm() {
if (invoice) return null;
return (
<>
<h3>
<FormattedMessage {...messages.ZapAmount} />
</h3>
<div className="amounts" ref={horizontalScroll}>
{serviceAmounts.map(a => (
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
{emojis[a] && <>{emojis[a]}&nbsp;</>}
{formatShort(a)}
</span>
))}
</div>
{payService && custom()}
<div className="flex">
{canComment && (
<input
type="text"
placeholder={formatMessage(messages.Comment)}
className="f-grow"
maxLength={payService?.commentAllowed || 120}
onChange={e => setComment(e.target.value)}
/>
)}
</div>
{(amount ?? 0) > 0 && (
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
<div className="zap-action-container">
<Zap />
{target ? (
<FormattedMessage {...messages.ZapTarget} values={{ target, n: formatShort(amount) }} />
) : (
<FormattedMessage {...messages.ZapSats} values={{ n: formatShort(amount) }} />
)}
</div>
</button>
)}
</>
);
}
function payInvoice() {
if (success) return null;
const pr = invoice?.pr;
return (
<>
<div className="invoice">
{props.notice && <b className="error">{props.notice}</b>}
<QrCode data={pr} link={`lightning:${pr}`} />
<div className="actions">
{pr && (
<>
<div className="copy-action">
<Copy text={pr} maxSize={26} />
</div>
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${pr}`)}>
<FormattedMessage {...messages.OpenWallet} />
</button>
</>
)}
</div>
</div>
</>
);
}
function successAction() {
if (!success) return null;
return (
<div className="success-action">
<p className="paid">
<Check className="success mr10" />
{success?.description ?? <FormattedMessage {...messages.Paid} />}
</p>
{success.url && (
<p>
<a href={success.url} rel="noreferrer" target="_blank">
{success.url}
</a>
</p>
)}
</div>
);
}
const defaultTitle = payService?.nostrPubkey ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats);
const title = target
? formatMessage(messages.ToTarget, {
action: defaultTitle,
target,
})
: defaultTitle;
if (!show) return null;
return (
<Modal className="lnurl-modal" onClose={onClose}>
<div className="lnurl-tip" onClick={e => e.stopPropagation()}>
<div className="close" onClick={onClose}>
<Close />
</div>
<div className="lnurl-header">
{author && <ProfileImage pubkey={author} showUsername={false} />}
<h2>{props.title || title}</h2>
</div>
{invoiceForm()}
{error && <p className="error">{error}</p>}
{payInvoice()}
{successAction()}
</div>
</Modal>
);
}

View File

@ -0,0 +1,14 @@
.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;
}

View File

@ -0,0 +1,25 @@
import "./ShowMore.css";
import { useIntl } from "react-intl";
import messages from "./messages";
interface ShowMoreProps {
text?: string;
className?: string;
onClick: () => void;
}
const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
const { formatMessage } = useIntl();
const defaultText = formatMessage(messages.ShowMore);
const classNames = className ? `show-more ${className}` : "show-more";
return (
<div className="show-more-container">
<button className={classNames} onClick={onClick}>
{text || defaultText}
</button>
</div>
);
};
export default ShowMore;

View File

@ -0,0 +1,42 @@
.skeleton {
display: inline-block;
height: 1em;
position: relative;
overflow: hidden;
background-color: #dddbdd;
border-radius: 16px;
}
.skeleton::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
content: "";
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
@media screen and (prefers-color-scheme: dark) {
.skeleton {
background-color: #50535a;
}
.skeleton::after {
background-image: linear-gradient(90deg, #50535a 0%, #656871 20%, #50535a 40%, #50535a 100%);
}
}

View File

@ -0,0 +1,21 @@
import "./Skeleton.css";
interface ISkepetonProps {
children?: React.ReactNode;
loading?: boolean;
width?: string;
height?: string;
margin?: string;
}
export default function Skeleton({ children, width, height, margin, loading = true }: ISkepetonProps) {
if (!loading) {
return <>{children}</>;
}
return (
<div className="skeleton" style={{ width: width, height: height, margin: margin }}>
{children}
</div>
);
}

View File

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

View File

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

View File

@ -0,0 +1,42 @@
.tabs {
display: flex;
align-items: center;
flex-direction: row;
overflow-x: scroll;
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* Firefox */
margin-bottom: 18px;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab {
color: var(--font-tertiary-color);
border: 1px solid var(--font-tertiary-color);
border-radius: 16px;
text-align: center;
font-weight: 600;
line-height: 19px;
padding: 8px 12px;
font-weight: 600;
font-size: 14px;
line-height: 17px;
margin-right: 12px;
}
.tab.active {
border-color: var(--font-color);
color: var(--font-color);
}
.tabs > div {
cursor: pointer;
}
.tab.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}

View File

@ -0,0 +1,41 @@
import "./Tabs.css";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
export interface Tab {
text: string;
value: number;
disabled?: boolean;
}
interface TabsProps {
tabs: Tab[];
tab: Tab;
setTab: (t: Tab) => void;
}
interface TabElementProps extends Omit<TabsProps, "tabs"> {
t: Tab;
}
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
return (
<div
className={`tab ${tab.value === t.value ? "active" : ""} ${t.disabled ? "disabled" : ""}`}
onClick={() => !t.disabled && setTab(t)}>
{t.text}
</div>
);
};
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
const horizontalScroll = useHorizontalScroll();
return (
<div className="tabs" ref={horizontalScroll}>
{tabs.map(t => (
<TabElement tab={tab} setTab={setTab} t={t} />
))}
</div>
);
};
export default Tabs;

View File

@ -0,0 +1,82 @@
.text {
font-size: var(--font-size);
line-height: 24px;
}
.text a {
color: var(--highlight);
text-decoration: none;
}
.text a:hover {
text-decoration: underline;
}
.text h1 {
margin: 0;
}
.text h2 {
margin: 0;
}
.text h3 {
margin: 0;
}
.text h4 {
margin: 0;
}
.text h5 {
margin: 0;
}
.text h6 {
margin: 0;
}
.text p {
margin: 0;
margin-bottom: 4px;
}
.text p:last-child {
margin-bottom: 0;
}
.text pre {
margin: 0;
}
.text li {
margin-top: -1em;
}
.text li:last-child {
margin-bottom: -2em;
}
.text hr {
border: 0;
height: 1px;
background-image: var(--gray-gradient);
margin: 20px;
}
.text img,
.text video,
.text iframe,
.text audio {
max-width: 100%;
max-height: 500px;
margin: 10px auto;
display: block;
border-radius: 12px;
}
.text iframe,
.text video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.text blockquote {
margin: 0;
color: var(--font-secondary-color);
border-left: 2px solid var(--font-secondary-color);
padding-left: 12px;
}

View File

@ -0,0 +1,184 @@
import "./Text.css";
import { useMemo, useCallback } from "react";
import { Link } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { visit, SKIP } from "unist-util-visit";
import { UrlRegex, MentionRegex, InvoiceRegex, HashtagRegex } from "Const";
import { eventLink, hexToBech32, unwrap } from "Util";
import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag";
import { Tag } from "@snort/nostr";
import { MetadataCache } from "State/Users";
import Mention from "Element/Mention";
import HyperText from "Element/HyperText";
import { HexKey } from "@snort/nostr";
import * as unist from "unist";
export type Fragment = string | React.ReactNode;
export interface TextFragment {
body: React.ReactNode[];
tags: Tag[];
users: Map<string, MetadataCache>;
}
export interface TextProps {
content: string;
creator: HexKey;
tags: Tag[];
users: Map<string, MetadataCache>;
}
export default function Text({ content, tags, creator, users }: TextProps) {
function extractLinks(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(UrlRegex).map(a => {
if (a.startsWith("http")) {
return <HyperText key={a} link={a} creator={creator} />;
}
return a;
});
}
return f;
})
.flat();
}
function extractMentions(frag: TextFragment) {
return frag.body
.map(f => {
if (typeof f === "string") {
return f.split(MentionRegex).map(match => {
const matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
const idx = parseInt(matchTag[1]);
const ref = frag.tags?.find(a => a.Index === idx);
if (ref) {
switch (ref.Key) {
case "p": {
return <Mention key={ref.PubKey} pubkey={ref.PubKey ?? ""} />;
}
case "e": {
const eText = hexToBech32("note", ref.Event).substring(0, 12);
return (
<Link key={ref.Event} to={eventLink(ref.Event ?? "")} onClick={e => e.stopPropagation()}>
#{eText}
</Link>
);
}
case "t": {
return <Hashtag key={ref.Hashtag} tag={ref.Hashtag ?? ""} />;
}
}
}
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
} else {
return match;
}
});
}
return f;
})
.flat();
}
function extractInvoices(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(InvoiceRegex).map(i => {
if (i.toLowerCase().startsWith("lnbc")) {
return <Invoice key={i} invoice={i} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function extractHashtags(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string") {
return f.split(HashtagRegex).map(i => {
if (i.toLowerCase().startsWith("#")) {
return <Hashtag key={i} tag={i.substring(1)} />;
} else {
return i;
}
});
}
return f;
})
.flat();
}
function transformLi(frag: TextFragment) {
const fragments = transformText(frag);
return <li>{fragments}</li>;
}
function transformParagraph(frag: TextFragment) {
const fragments = transformText(frag);
if (fragments.every(f => typeof f === "string")) {
return <p>{fragments}</p>;
}
return <>{fragments}</>;
}
function transformText(frag: TextFragment) {
let fragments = extractMentions(frag);
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
return fragments;
}
const components = useMemo(() => {
return {
p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags, users }),
a: (x: { href?: string }) => <HyperText link={x.href ?? ""} creator={creator} />,
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags, users }),
};
}, [content]);
interface Node extends unist.Node<unist.Data> {
value: string;
}
const disableMarkdownLinks = useCallback(
() => (tree: Node) => {
visit(tree, (node, index, parent) => {
if (
parent &&
typeof index === "number" &&
(node.type === "link" ||
node.type === "linkReference" ||
node.type === "image" ||
node.type === "imageReference" ||
node.type === "definition")
) {
node.type = "text";
const position = unwrap(node.position);
node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )");
return SKIP;
}
});
},
[content]
);
return (
<div dir="auto">
<ReactMarkdown className="text" components={components} remarkPlugins={[disableMarkdownLinks]}>
{content}
</ReactMarkdown>
</div>
);
}

View File

@ -0,0 +1,69 @@
.rta__list {
border: none;
}
.rta__item:not(:last-child) {
border: none;
}
.rta__entity--selected .user-item,
.rta__entity--selected .emoji-item {
text-decoration: none;
background: var(--gray-secondary);
}
.user-item,
.emoji-item {
color: var(--font-color);
background: var(--note-bg);
display: flex;
flex-direction: row;
align-items: center;
font-size: 16px;
padding: 10px;
}
.user-item:hover,
.emoji-item:hover {
background: var(--gray-tertiary);
}
.user-item .picture {
width: 30px;
height: 30px;
border-radius: 100%;
}
.user-picture {
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
}
.user-picture .avatar {
border-width: 1px;
width: 40px;
height: 40px;
}
.user-details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.user-item .nip05 {
font-size: var(--font-size-tiny);
}
.emoji-item {
font-size: var(--font-size-tiny);
}
.emoji-item .emoji {
margin-right: 0.2em;
min-width: 20px;
}
.emoji-item .emoji-name {
font-weight: bold;
}

View File

@ -0,0 +1,97 @@
import "@webscopeio/react-textarea-autocomplete/style.css";
import "./Textarea.css";
import { useState } from "react";
import { useIntl } from "react-intl";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import emoji from "@jukben/emoji-search";
import TextareaAutosize from "react-textarea-autosize";
import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { hexToBech32 } from "Util";
import { MetadataCache } from "State/Users";
import { useQuery } from "State/Users/Hooks";
import messages from "./messages";
interface EmojiItemProps {
name: string;
char: string;
}
const EmojiItem = ({ entity: { name, char } }: { entity: EmojiItemProps }) => {
return (
<div className="emoji-item">
<div className="emoji">{char}</div>
<div className="emoji-name">{name}</div>
</div>
);
};
const UserItem = (metadata: MetadataCache) => {
const { pubkey, display_name, nip05, ...rest } = metadata;
return (
<div key={pubkey} className="user-item">
<div className="user-picture">
<Avatar user={metadata} />
</div>
<div className="user-details">
<strong>{display_name || rest.name}</strong>
<Nip05 nip05={nip05} pubkey={pubkey} />
</div>
</div>
);
};
interface TextareaProps {
autoFocus: boolean;
className: string;
onChange(ev: React.ChangeEvent<HTMLTextAreaElement>): void;
value: string;
onFocus(): void;
}
const Textarea = (props: TextareaProps) => {
const [query, setQuery] = useState("");
const { formatMessage } = useIntl();
const allUsers = useQuery(query);
const userDataProvider = (token: string) => {
setQuery(token);
return allUsers ?? [];
};
const emojiDataProvider = (token: string) => {
return emoji(token)
.slice(0, 5)
.map(({ name, char }) => ({ name, char }));
};
return (
// @ts-expect-error If anybody can figure out how to type this, please do
<ReactTextareaAutocomplete
dir="auto"
{...props}
loadingComponent={() => <span>Loading...</span>}
placeholder={formatMessage(messages.NotePlaceholder)}
textAreaComponent={TextareaAutosize}
trigger={{
":": {
dataProvider: emojiDataProvider,
component: EmojiItem,
output: (item: EmojiItemProps) => item.char,
},
"@": {
afterWhitespace: true,
dataProvider: userDataProvider,
component: (props: { entity: MetadataCache }) => <UserItem {...props.entity} />,
output: (item: { pubkey: string }) => `@${hexToBech32("npub", item.pubkey)}`,
},
}}
/>
);
};
export default Textarea;

View File

@ -0,0 +1,167 @@
.thread-container {
margin: 12px 0 150px 0;
}
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-root.note {
box-shadow: none;
}
.thread-root.note > .body {
margin-top: 8px;
padding-left: 8px;
}
.thread-root.note > .body .text {
font-size: 19px;
}
.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-radius: 0;
margin-bottom: 0;
}
.light .thread-note.note.card {
box-shadow: none;
}
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-container .show-more {
background: var(--note-bg);
padding-left: 76px;
width: 100%;
text-align: left;
border-radius: 0;
padding-top: 10px;
padding-bottom: 10px;
}
.subthread-container {
position: relative;
}
.line-container {
background: var(--note-bg);
}
.subthread-container.subthread-multi .line-container:before {
content: "";
position: absolute;
left: 36px;
top: 48px;
border-left: 1px solid var(--gray-superdark);
height: 100%;
}
@media (min-width: 720px) {
.subthread-container.subthread-multi .line-container:before {
left: 48px;
}
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: "";
position: absolute;
left: 36px;
top: 48px;
border-left: 1px solid var(--gray-superdark);
height: 100%;
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
left: 48px;
}
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
content: "";
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
top: 0;
height: 48px;
}
@media (min-width: 720px) {
.subthread-container.subthread-mid:not(.subthread-last) .line-container:after {
left: 48px;
}
}
.subthread-container.subthread-last .line-container:before {
content: "";
position: absolute;
border-left: 1px solid var(--gray-superdark);
left: 36px;
top: 0;
height: 48px;
}
@media (min-width: 720px) {
.subthread-container.subthread-last .line-container:before {
left: 48px;
}
}
.divider-container {
background: var(--note-bg);
}
.divider {
height: 1px;
background: var(--gray-superdark);
margin-left: 28px;
margin-right: 22px;
}
.divider.divider-small {
margin-left: 80px;
}
.thread-container .collapsed,
.thread-container .show-more-container {
background: var(--note-bg);
min-height: 48px;
}
.thread-note.is-last-note {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.thread-container .collapsed {
background-color: var(--note-bg);
}
.thread-container .hidden-note {
padding-left: 48px;
}
.thread-root.thread-root-single.note {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.thread-root.ghost-root {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}

View File

@ -0,0 +1,409 @@
import "./Thread.css";
import { useMemo, useState, useEffect, ReactNode } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate, useLocation, Link } from "react-router-dom";
import { TaggedRawEvent, u256, HexKey } from "@snort/nostr";
import { Event as NEvent, EventKind } from "@snort/nostr";
import { eventLink, bech32ToHex, unwrap } from "Util";
import BackButton from "Element/BackButton";
import Note from "Element/Note";
import NoteGhost from "Element/NoteGhost";
import Collapsed from "Element/Collapsed";
import messages from "./messages";
function getParent(ev: HexKey, chains: Map<HexKey, NEvent[]>): HexKey | undefined {
for (const [k, vs] of chains.entries()) {
const fs = vs.map(a => a.Id);
if (fs.includes(ev)) {
return k;
}
}
}
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;
from: u256;
active: u256;
path: u256[];
notes: NEvent[];
related: TaggedRawEvent[];
chains: Map<u256, NEvent[]>;
onNavigate: (e: u256) => void;
}
const Subthread = ({ active, path, notes, related, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: NEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.Id, chains);
return (
<>
<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-ev={a}
key={a.Id}
related={related}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
path={path}
from={a.Id}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
</>
);
};
return <div className="subthread">{notes.map(renderSubthread)}</div>;
};
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: NEvent;
isLast: boolean;
}
const ThreadNote = ({
active,
note,
isLast,
path,
isLastSubthread,
from,
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 > 0;
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
const className = `subthread-container ${isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid"}`;
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.Id}
className={`thread-note ${isLastVisibleNote ? "is-last-note" : ""}`}
data-ev={note}
key={note.Id}
related={related}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 &&
(activeInReplies ? (
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
) : (
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
path={path}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
))}
</>
);
};
const TierTwo = ({ active, isLastSubthread, path, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
return (
<>
<ThreadNote
active={active}
path={path}
from={from}
onNavigate={onNavigate}
note={first}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={rest.length === 0}
/>
{rest.map((r: NEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
return (
<ThreadNote
active={active}
path={path}
from={from}
onNavigate={onNavigate}
note={r}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={lastReply}
/>
);
})}
</>
);
};
const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.Id, chains);
const activeInReplies = notes.map(r => r.Id).includes(active) || replies.map(r => r.Id).includes(active);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
const isLast = replies.length === 0 && rest.length === 0;
return (
<>
<div
className={`subthread-container ${hasMultipleNotes ? "subthread-multi" : ""} ${
isLast ? "subthread-last" : "subthread-mid"
}`}>
<Divider variant="small" />
<Note
highlight={active === first.Id}
className={`thread-note ${isLastSubthread && isLast ? "is-last-note" : ""}`}
data-ev={first}
key={first.Id}
related={related}
/>
<div className="line-container"></div>
</div>
{path.length <= 1 || !activeInReplies
? replies.length > 0 && (
<div className="show-more-container">
<button className="show-more" type="button" onClick={() => onNavigate(from)}>
<FormattedMessage {...messages.ShowReplies} />
</button>
</div>
)
: replies.length > 0 && (
<TierThree
active={active}
path={path.slice(1)}
isLastSubthread={isLastSubthread}
from={from}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
{rest.map((r: NEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
return (
<div
key={r.Id}
className={`subthread-container ${lastReply ? "" : "subthread-multi"} ${
lastReply ? "subthread-last" : "subthread-mid"
}`}>
<Divider variant="small" />
<Note
className={`thread-note ${lastNote ? "is-last-note" : ""}`}
highlight={active === r.Id}
data-ev={r}
key={r.Id}
related={related}
/>
<div className="line-container"></div>
</div>
);
})}
</>
);
};
export interface ThreadProps {
this?: u256;
notes?: TaggedRawEvent[];
}
export default function Thread(props: ThreadProps) {
const notes = props.notes ?? [];
const parsedNotes = notes.map(a => new NEvent(a));
// root note has no thread info
const root = useMemo(() => parsedNotes.find(a => a.Thread === null), [notes]);
const [path, setPath] = useState<HexKey[]>([]);
const currentId = path.length > 0 && path[path.length - 1];
const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === currentId), [notes, currentId]);
const [navigated, setNavigated] = useState(false);
const navigate = useNavigate();
const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1;
const location = useLocation();
const { formatMessage } = useIntl();
const urlNoteId = location?.pathname.slice(3);
const urlNoteHex = urlNoteId && bech32ToHex(urlNoteId);
const chains = useMemo(() => {
const chains = new Map<u256, NEvent[]>();
parsedNotes
?.filter(a => a.Kind === EventKind.TextNote)
.sort((a, b) => b.CreatedAt - a.CreatedAt)
.forEach(v => {
const replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
if (replyTo) {
if (!chains.has(replyTo)) {
chains.set(replyTo, [v]);
} else {
unwrap(chains.get(replyTo)).push(v);
}
} else if (v.Tags.length > 0) {
console.log("Not replying to anything: ", v);
}
});
return chains;
}, [notes]);
useEffect(() => {
if (!root) {
return;
}
if (navigated) {
return;
}
if (root.Id === urlNoteHex) {
setPath([root.Id]);
setNavigated(true);
return;
}
const subthreadPath = [];
let parent = getParent(urlNoteHex, chains);
while (parent) {
subthreadPath.unshift(parent);
parent = getParent(parent, chains);
}
setPath(subthreadPath);
setNavigated(true);
}, [root, navigated, urlNoteHex, chains]);
const brokenChains = useMemo(() => {
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
}, [chains]);
function renderRoot(note: NEvent) {
const className = `thread-root ${isSingleNote ? "thread-root-single" : ""}`;
if (note) {
return <Note className={className} key={note.Id} data-ev={note} related={notes} />;
} else {
return <NoteGhost className={className}>Loading thread root.. ({notes?.length} notes loaded)</NoteGhost>;
}
}
function onNavigate(to: u256) {
setPath([...path, to]);
}
function renderChain(from: u256): ReactNode {
if (!from || !chains) {
return;
}
const replies = chains.get(from);
if (replies) {
return (
<Subthread
active={urlNoteHex}
path={path}
from={from}
notes={replies}
related={notes}
chains={chains}
onNavigate={onNavigate}
/>
);
}
}
function goBack() {
if (path.length > 1) {
const newPath = path.slice(0, path.length - 1);
setPath(newPath);
} else {
navigate("/");
}
}
const parentText = formatMessage({
defaultMessage: "Parent",
description: "Link to parent note in thread",
});
const backText = formatMessage({
defaultMessage: "Back",
description: "Navigate back button on threads view",
});
return (
<div className="main-content mt10">
<BackButton onClick={goBack} text={path?.length > 1 ? parentText : backText} />
<div className="thread-container">
{currentRoot && renderRoot(currentRoot)}
{currentRoot && renderChain(currentRoot.Id)}
{currentRoot === root && (
<>
{brokenChains.length > 0 && <h3>Other replies</h3>}
{brokenChains.map(a => {
return (
<div className="mb10">
<NoteGhost className={`thread-note thread-root ghost-root`} key={a}>
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
</NoteGhost>
{renderChain(a)}
</div>
);
})}
</>
)}
</div>
</div>
);
}
function getReplies(from: u256, chains?: Map<u256, NEvent[]>): NEvent[] {
if (!from || !chains) {
return [];
}
const replies = chains.get(from);
return replies ? replies : [];
}

View File

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

View File

@ -0,0 +1,5 @@
.latest-notes {
cursor: pointer;
font-weight: bold;
user-select: none;
}

View File

@ -0,0 +1,101 @@
import "./Timeline.css";
import { FormattedMessage } from "react-intl";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faForward } from "@fortawesome/free-solid-svg-icons";
import { useCallback, useMemo } from "react";
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
import { TaggedRawEvent } from "@snort/nostr";
import { EventKind } from "@snort/nostr";
import LoadMore from "Element/LoadMore";
import Zap, { parseZap } from "Element/Zap";
import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction";
import useModeration from "Hooks/useModeration";
import ProfilePreview from "./ProfilePreview";
import Skeleton from "Element/Skeleton";
import messages from "./messages";
export interface TimelineProps {
postsOnly: boolean;
subject: TimelineSubject;
method: "TIME_RANGE" | "LIMIT_UNTIL";
ignoreModeration?: boolean;
window?: number;
relay?: string;
}
/**
* A list of notes by pubkeys
*/
export default function Timeline({
subject,
postsOnly = false,
method,
ignoreModeration = false,
window,
relay,
}: TimelineProps) {
const { muted, isMuted } = useModeration();
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
method,
window: window,
relay,
});
const filterPosts = useCallback(
(nts: TaggedRawEvent[]) => {
return [...nts]
.sort((a, b) => b.created_at - a.created_at)
?.filter(a => (postsOnly ? !a.tags.some(b => b[0] === "e") : true))
.filter(a => ignoreModeration || !isMuted(a.pubkey));
},
[postsOnly, muted]
);
const mainFeed = useMemo(() => {
return filterPosts(main.notes);
}, [main, filterPosts]);
const latestFeed = useMemo(() => {
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id));
}, [latest, mainFeed, filterPosts]);
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview pubkey={e.pubkey} className="card" />;
}
case EventKind.TextNote: {
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />;
}
case EventKind.ZapReceipt: {
const zap = parseZap(e);
return zap.e ? null : <Zap zap={zap} key={e.id} />;
}
case EventKind.Reaction:
case EventKind.Repost: {
const eRef = e.tags.find(a => a[0] === "e")?.at(1);
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)} />;
}
}
}
return (
<div className="main-content">
{latestFeed.length > 1 && (
<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl" />{" "}
<FormattedMessage {...messages.ShowLatest} values={{ n: latestFeed.length - 1 }} />
</div>
)}
{mainFeed.map(eventElement)}
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}>
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
<Skeleton width="100%" height="120px" margin="0 0 16px 0" />
</LoadMore>
</div>
);
}

View File

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

View File

@ -0,0 +1,19 @@
.pill {
color: var(--font-color);
font-size: var(--font-size-small);
display: inline-block;
background-color: var(--gray-secondary);
padding: 2px 10px;
border-radius: 10px;
user-select: none;
margin: 2px 5px;
}
.pill.unread {
background-color: var(--gray);
color: var(--font-color);
}
.pill:hover {
cursor: pointer;
}

View File

@ -0,0 +1,7 @@
import "./UnreadCount.css";
const UnreadCount = ({ unread }: { unread: number }) => {
return <span className={`pill ${unread > 0 ? "unread" : ""}`}>{unread}</span>;
};
export default UnreadCount;

View File

@ -0,0 +1,93 @@
.zap {
min-height: unset;
}
.zap .header {
align-items: center;
flex-direction: row;
}
.zap .header .pfp {
overflow: hidden;
}
.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 {
margin-top: 8px;
display: flex;
flex-direction: row;
margin-left: 56px;
}
.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 .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: bold;
}
.zap.note .body {
margin-bottom: 0;
}

View File

@ -0,0 +1,138 @@
import "./Zap.css";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import { useSelector } from "react-redux";
import { decode as invoiceDecode } from "light-bolt11-decoder";
import { bytesToHex } from "@noble/hashes/utils";
import { sha256, unwrap } from "Util";
import { formatShort } from "Number";
import { HexKey, TaggedRawEvent } from "@snort/nostr";
import { Event } from "@snort/nostr";
import Text from "Element/Text";
import ProfileImage from "Element/ProfileImage";
import { RootState } from "State/Store";
import messages from "./messages";
function findTag(e: TaggedRawEvent, tag: string) {
const maybeTag = e.tags.find(evTag => {
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
}
function getInvoice(zap: TaggedRawEvent) {
const bolt11 = findTag(zap, "bolt11");
const decoded = invoiceDecode(bolt11);
const amount = decoded.sections.find(section => section.name === "amount")?.value;
const hash = decoded.sections.find(section => section.name === "description_hash")?.value;
return { amount, hash: hash ? bytesToHex(hash as Uint8Array) : undefined };
}
interface Zapper {
pubkey?: HexKey;
isValid: boolean;
}
function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
const zapRequest = findTag(zap, "description");
if (zapRequest) {
const rawEvent: TaggedRawEvent = JSON.parse(zapRequest);
if (Array.isArray(rawEvent)) {
// old format, ignored
return { isValid: false };
}
const metaHash = sha256(zapRequest);
const ev = new Event(rawEvent);
return { pubkey: ev.PubKey, isValid: dhash === metaHash };
}
return { isValid: false };
}
export interface ParsedZap {
id: HexKey;
e?: HexKey;
p: HexKey;
amount: number;
content: string;
zapper?: HexKey;
valid: boolean;
}
export function parseZap(zap: TaggedRawEvent): ParsedZap {
const { amount, hash } = getInvoice(zap);
const zapper = hash ? getZapper(zap, hash) : { isValid: false };
const e = findTag(zap, "e");
const p = unwrap(findTag(zap, "p"));
return {
id: zap.id,
e,
p,
amount: Number(amount) / 1000,
zapper: zapper.pubkey,
content: zap.content,
valid: zapper.isValid,
};
}
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
const { amount, content, zapper, valid, p } = zap;
const pubKey = useSelector((s: RootState) => s.login.publicKey);
return valid && zapper ? (
<div className="zap note card">
<div className="header">
<ProfileImage pubkey={zapper} />
{p !== pubKey && showZapped && <ProfileImage pubkey={p} />}
<div className="amount">
<span className="amount-number">
<FormattedMessage {...messages.Sats} values={{ n: formatShort(amount) }} />
</span>
</div>
</div>
{content.length > 0 && zapper && (
<div className="body">
<Text creator={zapper} content={content} tags={[]} users={new Map()} />
</div>
)}
</div>
) : null;
};
interface ZapsSummaryProps {
zaps: ParsedZap[];
}
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
const sortedZaps = useMemo(() => {
const pub = [...zaps.filter(z => z.zapper && z.valid)];
const priv = [...zaps.filter(z => !z.zapper && 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 { zapper, amount } = topZap;
return (
<div className="zaps-summary">
{amount && (
<div className={`top-zap`}>
<div className="summary">
{zapper && <ProfileImage pubkey={zapper} />}
{restZaps.length > 0 && <FormattedMessage {...messages.Others} values={{ n: restZaps.length }} />}{" "}
<FormattedMessage {...messages.OthersZapped} values={{ n: restZaps.length }} />
</div>
</div>
)}
</div>
);
};
export default Zap;

View File

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

View File

@ -0,0 +1,32 @@
import "./ZapButton.css";
import { faBolt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "@snort/nostr";
import SendSats from "Element/SendSats";
const ZapButton = ({ pubkey, svc }: { pubkey: HexKey; svc?: string }) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
const service = svc ?? (profile?.lud16 || profile?.lud06);
if (!service) return null;
return (
<>
<div className="zap-button" onClick={() => setZap(true)}>
<FontAwesomeIcon icon={faBolt} />
</div>
<SendSats
target={profile?.display_name || profile?.name}
svc={service}
show={zap}
onClose={() => setZap(false)}
author={pubkey}
/>
</>
);
};
export default ZapButton;

View File

@ -0,0 +1,105 @@
import { defineMessages } from "react-intl";
export default defineMessages({
Cancel: { defaultMessage: "Cancel" },
Reply: { defaultMessage: "Reply" },
Send: { defaultMessage: "Send" },
NotePlaceholder: { defaultMessage: "What's on your mind?" },
Back: { defaultMessage: "Back" },
Block: { defaultMessage: "Block" },
Unblock: { defaultMessage: "Unblock" },
MuteCount: { defaultMessage: "{n} muted" },
Mute: { defaultMessage: "Mute" },
MutedAuthor: { defaultMessage: "This author has been muted" },
Others: { defaultMessage: ` & {n} {n, plural, =1 {other} other {others}}` },
Show: { defaultMessage: "Show" },
Delete: { defaultMessage: "Delete" },
Deleted: { defaultMessage: "Deleted" },
Unmute: { defaultMessage: "Unmute" },
MuteAll: { defaultMessage: "Mute all" },
BlockCount: { defaultMessage: "{n} blocked" },
JustNow: { defaultMessage: "Just now" },
Follow: { defaultMessage: "Follow" },
FollowAll: { defaultMessage: "Follow all" },
Unfollow: { defaultMessage: "Unfollow" },
FollowerCount: { defaultMessage: "{n} followers" },
FollowingCount: { defaultMessage: "Follows {n}" },
FollowsYou: { defaultMessage: "follows you" },
Invoice: { defaultMessage: "Lightning Invoice" },
PayInvoice: { defaultMessage: "Pay Invoice" },
Expired: { defaultMessage: "Expired" },
Pay: { defaultMessage: "Pay" },
Paid: { defaultMessage: "Paid" },
Loading: { defaultMessage: "Loading..." },
Logout: { defaultMessage: "Logout" },
ShowMore: { defaultMessage: "Show more" },
TranslateTo: { defaultMessage: "Translate to {lang}" },
TranslatedFrom: { defaultMessage: "Translated from {lang}" },
TranslationFailed: { defaultMessage: "Translation failed" },
UnknownEventKind: { defaultMessage: "Unknown event kind: {kind}" },
ConfirmDeletion: { defaultMessage: `Are you sure you want to delete {id}` },
ConfirmRepost: { defaultMessage: `Are you sure you want to repost: {id}` },
Reactions: { defaultMessage: "Reactions" },
ReactionsCount: { defaultMessage: "Reactions ({n})" },
Share: { defaultMessage: "Share" },
CopyID: { defaultMessage: "Copy ID" },
CopyJSON: { defaultMessage: "Copy Event JSON" },
Dislike: { defaultMessage: "{n} Dislike" },
DislikeAction: { defaultMessage: "Dislike" },
Sats: { defaultMessage: `{n} {n, plural, =1 {sat} other {sats}}` },
Zapped: { defaultMessage: "zapped" },
OthersZapped: { defaultMessage: `{n, plural, =0 {} =1 {zapped} other {zapped}}` },
Likes: { defaultMessage: "Likes ({n})" },
Zaps: { defaultMessage: "Zaps ({n})" },
Dislikes: { defaultMessage: "Dislikes ({n})" },
Reposts: { defaultMessage: "Reposts ({n})" },
NoteToSelf: { defaultMessage: "Note to Self" },
Read: { defaultMessage: "Read" },
Write: { defaultMessage: "Write" },
Seconds: { defaultMessage: "{n} secs" },
Milliseconds: { defaultMessage: "{n} ms" },
ShowLatest: { defaultMessage: "Show latest {n} notes" },
LNURLFail: { defaultMessage: "Failed to load LNURL service" },
InvoiceFail: { defaultMessage: "Failed to load invoice" },
Custom: { defaultMessage: "Custom" },
Confirm: { defaultMessage: "Confirm" },
ZapAmount: { defaultMessage: "Zap amount in sats" },
Comment: { defaultMessage: "Comment" },
ZapTarget: { defaultMessage: "Zap {target} {n} sats" },
ZapSats: { defaultMessage: "Zap {n} sats" },
OpenWallet: { defaultMessage: "Open Wallet" },
SendZap: { defaultMessage: "Send zap" },
SendSats: { defaultMessage: "Send sats" },
ToTarget: { defaultMessage: "{action} to {target}" },
ShowReplies: { defaultMessage: "Show replies" },
TooShort: { defaultMessage: "name too short" },
TooLong: { defaultMessage: "name too long" },
Regex: { defaultMessage: "name has disallowed characters" },
Registered: { defaultMessage: "name is registered" },
Disallowed: { defaultMessage: "name is blocked" },
DisalledLater: { defaultMessage: "name will be available later" },
BuyNow: { defaultMessage: "Buy Now" },
NotAvailable: { defaultMessage: "Not available:" },
Buying: { defaultMessage: "Buying {item}" },
OrderPaid: { defaultMessage: "Order Paid!" },
NewNip: { defaultMessage: "Your new NIP-05 handle is:" },
ActivateNow: { defaultMessage: "Activate Now" },
AddToProfile: { defaultMessage: "Add to Profile" },
AccountPage: { defaultMessage: "account page" },
AccountSupport: { defaultMessage: "Account Support" },
GoTo: { defaultMessage: "Go to" },
FindMore: { defaultMessage: "Find out more info about {service} at {link}" },
SavePassword: {
defaultMessage: "Please make sure to save the following password in order to manage your handle in the future",
},
Handle: { defaultMessage: "Handle" },
Pin: { defaultMessage: "Pin" },
Pinned: { defaultMessage: "Pinned" },
Bookmark: { defaultMessage: "Bookmark" },
Bookmarks: { defaultMessage: "Bookmarks" },
BookmarksCount: { defaultMessage: "Bookmarks ({n})" },
Bookmarked: { defaultMessage: "Saved" },
All: { defaultMessage: "All" },
ConfirmUnbookmark: { defaultMessage: "Are you sure you want to remove this note from bookmarks?" },
ConfirmUnpin: { defaultMessage: "Are you sure you want to unpin this note?" },
});