workspace with decoupled nostr package
This commit is contained in:
16
packages/app/src/Element/AppleMusicEmbed.tsx
Normal file
16
packages/app/src/Element/AppleMusicEmbed.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
const AppleMusicEmbed = ({ link }: { link: string }) => {
|
||||
const convertedUrl = link.replace("music.apple.com", "embed.music.apple.com");
|
||||
const isSongLink = /\?i=\d+$/.test(convertedUrl);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
|
||||
frameBorder="0"
|
||||
height={isSongLink ? 175 : 450}
|
||||
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
|
||||
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
|
||||
src={convertedUrl}></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppleMusicEmbed;
|
31
packages/app/src/Element/AsyncButton.tsx
Normal file
31
packages/app/src/Element/AsyncButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
packages/app/src/Element/Avatar.css
Normal file
19
packages/app/src/Element/Avatar.css
Normal 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);
|
||||
}
|
25
packages/app/src/Element/Avatar.tsx
Normal file
25
packages/app/src/Element/Avatar.tsx
Normal 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;
|
21
packages/app/src/Element/BackButton.css
Normal file
21
packages/app/src/Element/BackButton.css
Normal 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;
|
||||
}
|
29
packages/app/src/Element/BackButton.tsx
Normal file
29
packages/app/src/Element/BackButton.tsx
Normal 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;
|
24
packages/app/src/Element/BlockButton.tsx
Normal file
24
packages/app/src/Element/BlockButton.tsx
Normal 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;
|
42
packages/app/src/Element/BlockList.tsx
Normal file
42
packages/app/src/Element/BlockList.tsx
Normal 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>
|
||||
);
|
||||
}
|
61
packages/app/src/Element/Bookmarks.tsx
Normal file
61
packages/app/src/Element/Bookmarks.tsx
Normal 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;
|
56
packages/app/src/Element/Collapsed.tsx
Normal file
56
packages/app/src/Element/Collapsed.tsx
Normal 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;
|
14
packages/app/src/Element/Copy.css
Normal file
14
packages/app/src/Element/Copy.css
Normal 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;
|
||||
}
|
23
packages/app/src/Element/Copy.tsx
Normal file
23
packages/app/src/Element/Copy.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
packages/app/src/Element/DM.css
Normal file
23
packages/app/src/Element/DM.css
Normal 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);
|
||||
}
|
61
packages/app/src/Element/DM.tsx
Normal file
61
packages/app/src/Element/DM.tsx
Normal 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>
|
||||
);
|
||||
}
|
2
packages/app/src/Element/FollowButton.css
Normal file
2
packages/app/src/Element/FollowButton.css
Normal file
@ -0,0 +1,2 @@
|
||||
.follow-button {
|
||||
}
|
39
packages/app/src/Element/FollowButton.tsx
Normal file
39
packages/app/src/Element/FollowButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
packages/app/src/Element/FollowListBase.tsx
Normal file
35
packages/app/src/Element/FollowListBase.tsx
Normal 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>
|
||||
);
|
||||
}
|
27
packages/app/src/Element/FollowersList.tsx
Normal file
27
packages/app/src/Element/FollowersList.tsx
Normal 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 })} />;
|
||||
}
|
24
packages/app/src/Element/FollowsList.tsx
Normal file
24
packages/app/src/Element/FollowsList.tsx
Normal 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 })} />;
|
||||
}
|
6
packages/app/src/Element/FollowsYou.css
Normal file
6
packages/app/src/Element/FollowsYou.css
Normal 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;
|
||||
}
|
29
packages/app/src/Element/FollowsYou.tsx
Normal file
29
packages/app/src/Element/FollowsYou.tsx
Normal 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;
|
||||
}
|
3
packages/app/src/Element/Hashtag.css
Normal file
3
packages/app/src/Element/Hashtag.css
Normal file
@ -0,0 +1,3 @@
|
||||
.hashtag {
|
||||
color: var(--highlight);
|
||||
}
|
14
packages/app/src/Element/Hashtag.tsx
Normal file
14
packages/app/src/Element/Hashtag.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./Hashtag.css";
|
||||
|
||||
const Hashtag = ({ tag }: { tag: string }) => {
|
||||
return (
|
||||
<span className="hashtag">
|
||||
<Link to={`/t/${tag}`} onClick={e => e.stopPropagation()}>
|
||||
#{tag}
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hashtag;
|
140
packages/app/src/Element/HyperText.tsx
Normal file
140
packages/app/src/Element/HyperText.tsx
Normal 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();
|
||||
}
|
16
packages/app/src/Element/IconButton.tsx
Normal file
16
packages/app/src/Element/IconButton.tsx
Normal 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;
|
88
packages/app/src/Element/Invoice.css
Normal file
88
packages/app/src/Element/Invoice.css
Normal 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;
|
||||
}
|
114
packages/app/src/Element/Invoice.tsx
Normal file
114
packages/app/src/Element/Invoice.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
37
packages/app/src/Element/LoadMore.tsx
Normal file
37
packages/app/src/Element/LoadMore.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export default function LoadMore({
|
||||
onLoadMore,
|
||||
shouldLoadMore,
|
||||
children,
|
||||
}: {
|
||||
onLoadMore: () => void;
|
||||
shouldLoadMore: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
const [tick, setTick] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView === true && shouldLoadMore === true) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [inView, shouldLoadMore, tick]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
setTick(x => (x += 1));
|
||||
}, 500);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mb10">
|
||||
{children ?? <FormattedMessage {...messages.Loading} />}
|
||||
</div>
|
||||
);
|
||||
}
|
23
packages/app/src/Element/LogoutButton.tsx
Normal file
23
packages/app/src/Element/LogoutButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
25
packages/app/src/Element/Mention.tsx
Normal file
25
packages/app/src/Element/Mention.tsx
Normal 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>
|
||||
);
|
||||
}
|
26
packages/app/src/Element/MixCloudEmbed.tsx
Normal file
26
packages/app/src/Element/MixCloudEmbed.tsx
Normal 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;
|
27
packages/app/src/Element/Modal.css
Normal file
27
packages/app/src/Element/Modal.css
Normal 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;
|
||||
}
|
||||
}
|
43
packages/app/src/Element/Modal.tsx
Normal file
43
packages/app/src/Element/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
packages/app/src/Element/MuteButton.tsx
Normal file
24
packages/app/src/Element/MuteButton.tsx
Normal 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;
|
42
packages/app/src/Element/MutedList.tsx
Normal file
42
packages/app/src/Element/MutedList.tsx
Normal 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>
|
||||
);
|
||||
}
|
47
packages/app/src/Element/Nip05.css
Normal file
47
packages/app/src/Element/Nip05.css
Normal 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;
|
||||
}
|
75
packages/app/src/Element/Nip05.tsx
Normal file
75
packages/app/src/Element/Nip05.tsx
Normal 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;
|
304
packages/app/src/Element/Nip5Service.tsx
Normal file
304
packages/app/src/Element/Nip5Service.tsx
Normal 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} />
|
||||
@
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
198
packages/app/src/Element/Note.css
Normal file
198
packages/app/src/Element/Note.css
Normal 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;
|
||||
}
|
268
packages/app/src/Element/Note.tsx
Normal file
268
packages/app/src/Element/Note.tsx
Normal 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:
|
||||
{(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;
|
||||
}
|
149
packages/app/src/Element/NoteCreator.css
Normal file
149
packages/app/src/Element/NoteCreator.css
Normal 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;
|
||||
}
|
133
packages/app/src/Element/NoteCreator.tsx
Normal file
133
packages/app/src/Element/NoteCreator.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
345
packages/app/src/Element/NoteFooter.tsx
Normal file
345
packages/app/src/Element/NoteFooter.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
20
packages/app/src/Element/NoteGhost.tsx
Normal file
20
packages/app/src/Element/NoteGhost.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
packages/app/src/Element/NoteReaction.css
Normal file
23
packages/app/src/Element/NoteReaction.css
Normal 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;
|
||||
}
|
76
packages/app/src/Element/NoteReaction.tsx
Normal file
76
packages/app/src/Element/NoteReaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
65
packages/app/src/Element/NoteTime.tsx
Normal file
65
packages/app/src/Element/NoteTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
38
packages/app/src/Element/NoteToSelf.css
Normal file
38
packages/app/src/Element/NoteToSelf.css
Normal 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;
|
||||
}
|
56
packages/app/src/Element/NoteToSelf.tsx
Normal file
56
packages/app/src/Element/NoteToSelf.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
packages/app/src/Element/ProfileImage.css
Normal file
32
packages/app/src/Element/ProfileImage.css
Normal 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;
|
||||
}
|
71
packages/app/src/Element/ProfileImage.tsx
Normal file
71
packages/app/src/Element/ProfileImage.tsx
Normal 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;
|
||||
}
|
15
packages/app/src/Element/ProfilePreview.css
Normal file
15
packages/app/src/Element/ProfilePreview.css
Normal 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);
|
||||
}
|
44
packages/app/src/Element/ProfilePreview.tsx
Normal file
44
packages/app/src/Element/ProfilePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
packages/app/src/Element/ProxyImg.tsx
Normal file
22
packages/app/src/Element/ProxyImg.tsx
Normal 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} />;
|
||||
};
|
50
packages/app/src/Element/QrCode.tsx
Normal file
50
packages/app/src/Element/QrCode.tsx
Normal 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>;
|
||||
}
|
122
packages/app/src/Element/Reactions.css
Normal file
122
packages/app/src/Element/Reactions.css
Normal 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;
|
||||
}
|
||||
}
|
145
packages/app/src/Element/Reactions.tsx
Normal file
145
packages/app/src/Element/Reactions.tsx
Normal 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;
|
42
packages/app/src/Element/Relay.css
Normal file
42
packages/app/src/Element/Relay.css
Normal 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;
|
||||
}
|
107
packages/app/src/Element/Relay.tsx
Normal file
107
packages/app/src/Element/Relay.tsx
Normal 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(),
|
||||
})}
|
||||
|
||||
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
|
||||
</div>
|
||||
<div>
|
||||
<span className="icon-btn" onClick={() => navigate(state?.id ?? "")}>
|
||||
<FontAwesomeIcon icon={faGear} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
36
packages/app/src/Element/RelaysMetadata.css
Normal file
36
packages/app/src/Element/RelaysMetadata.css
Normal 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;
|
||||
}
|
||||
}
|
44
packages/app/src/Element/RelaysMetadata.tsx
Normal file
44
packages/app/src/Element/RelaysMetadata.tsx
Normal 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;
|
167
packages/app/src/Element/SendSats.css
Normal file
167
packages/app/src/Element/SendSats.css
Normal 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;
|
||||
}
|
319
packages/app/src/Element/SendSats.tsx
Normal file
319
packages/app/src/Element/SendSats.tsx
Normal 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]} </>}
|
||||
{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>
|
||||
);
|
||||
}
|
14
packages/app/src/Element/ShowMore.css
Normal file
14
packages/app/src/Element/ShowMore.css
Normal 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;
|
||||
}
|
25
packages/app/src/Element/ShowMore.tsx
Normal file
25
packages/app/src/Element/ShowMore.tsx
Normal 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;
|
42
packages/app/src/Element/Skeleton.css
Normal file
42
packages/app/src/Element/Skeleton.css
Normal 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%);
|
||||
}
|
||||
}
|
21
packages/app/src/Element/Skeleton.tsx
Normal file
21
packages/app/src/Element/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
12
packages/app/src/Element/SoundCloudEmded.tsx
Normal file
12
packages/app/src/Element/SoundCloudEmded.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
const SoundCloudEmbed = ({ link }: { link: string }) => {
|
||||
return (
|
||||
<iframe
|
||||
width="100%"
|
||||
height="166"
|
||||
scrolling="no"
|
||||
allow="autoplay"
|
||||
src={`https://w.soundcloud.com/player/?url=${link}`}></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoundCloudEmbed;
|
16
packages/app/src/Element/SpotifyEmbed.tsx
Normal file
16
packages/app/src/Element/SpotifyEmbed.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
const SpotifyEmbed = ({ link }: { link: string }) => {
|
||||
const convertedUrl = link.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
|
||||
|
||||
return (
|
||||
<iframe
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
height="352"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotifyEmbed;
|
42
packages/app/src/Element/Tabs.css
Normal file
42
packages/app/src/Element/Tabs.css
Normal 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;
|
||||
}
|
41
packages/app/src/Element/Tabs.tsx
Normal file
41
packages/app/src/Element/Tabs.tsx
Normal 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;
|
82
packages/app/src/Element/Text.css
Normal file
82
packages/app/src/Element/Text.css
Normal 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;
|
||||
}
|
184
packages/app/src/Element/Text.tsx
Normal file
184
packages/app/src/Element/Text.tsx
Normal 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>
|
||||
);
|
||||
}
|
69
packages/app/src/Element/Textarea.css
Normal file
69
packages/app/src/Element/Textarea.css
Normal 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;
|
||||
}
|
97
packages/app/src/Element/Textarea.tsx
Normal file
97
packages/app/src/Element/Textarea.tsx
Normal 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;
|
167
packages/app/src/Element/Thread.css
Normal file
167
packages/app/src/Element/Thread.css
Normal 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;
|
||||
}
|
409
packages/app/src/Element/Thread.tsx
Normal file
409
packages/app/src/Element/Thread.tsx
Normal 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 : [];
|
||||
}
|
57
packages/app/src/Element/TidalEmbed.tsx
Normal file
57
packages/app/src/Element/TidalEmbed.tsx
Normal 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;
|
5
packages/app/src/Element/Timeline.css
Normal file
5
packages/app/src/Element/Timeline.css
Normal file
@ -0,0 +1,5 @@
|
||||
.latest-notes {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
101
packages/app/src/Element/Timeline.tsx
Normal file
101
packages/app/src/Element/Timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
8
packages/app/src/Element/TwitchEmbed.tsx
Normal file
8
packages/app/src/Element/TwitchEmbed.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
const TwitchEmbed = ({ link }: { link: string }) => {
|
||||
const channel = link.split("/").slice(-1);
|
||||
|
||||
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
|
||||
return <iframe src={`https://player.twitch.tv/${args}`} className="w-max" allowFullScreen={true}></iframe>;
|
||||
};
|
||||
|
||||
export default TwitchEmbed;
|
19
packages/app/src/Element/UnreadCount.css
Normal file
19
packages/app/src/Element/UnreadCount.css
Normal 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;
|
||||
}
|
7
packages/app/src/Element/UnreadCount.tsx
Normal file
7
packages/app/src/Element/UnreadCount.tsx
Normal 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;
|
93
packages/app/src/Element/Zap.css
Normal file
93
packages/app/src/Element/Zap.css
Normal 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;
|
||||
}
|
138
packages/app/src/Element/Zap.tsx
Normal file
138
packages/app/src/Element/Zap.tsx
Normal 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;
|
7
packages/app/src/Element/ZapButton.css
Normal file
7
packages/app/src/Element/ZapButton.css
Normal file
@ -0,0 +1,7 @@
|
||||
.zap-button {
|
||||
color: var(--bg-color);
|
||||
background-color: var(--highlight);
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
}
|
32
packages/app/src/Element/ZapButton.tsx
Normal file
32
packages/app/src/Element/ZapButton.tsx
Normal 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;
|
105
packages/app/src/Element/messages.ts
Normal file
105
packages/app/src/Element/messages.ts
Normal 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?" },
|
||||
});
|
Reference in New Issue
Block a user