chore: rename Dirs
This commit is contained in:
27
src/Element/AsyncButton.tsx
Normal file
27
src/Element/AsyncButton.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useState } from "react"
|
||||
|
||||
export default function AsyncButton(props: any) {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function handle(e : any) {
|
||||
if(loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (typeof props.onClick === "function") {
|
||||
let f = props.onClick(e);
|
||||
if (f instanceof Promise) {
|
||||
await f;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props} className={`btn ${props.className}${loading ? "disabled" : ""}`} onClick={(e) => handle(e)}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
39
src/Element/Avatar.css
Normal file
39
src/Element/Avatar.css
Normal file
@ -0,0 +1,39 @@
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
height: 210px;
|
||||
width: 210px;
|
||||
background-image: var(--img-url), var(--gray-gradient);
|
||||
border: 2px 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="nostrplebs.com"] {
|
||||
background-image: var(--img-url), var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.avatar[data-domain="nostrpurple.com"] {
|
||||
background-image: var(--img-url), var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.avatar[data-domain="nostr.fan"] {
|
||||
background-image: var(--img-url), var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.avatar[data-domain="nostrich.zone"] {
|
||||
background-image: var(--img-url), var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.avatar[data-domain="nostriches.net"] {
|
||||
background-image: var(--img-url), var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.avatar[data-domain="strike.army"] {
|
||||
background-image: var(--img-url), var(--strike-army-gradient);
|
||||
}
|
23
src/Element/Avatar.tsx
Normal file
23
src/Element/Avatar.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import "./Avatar.css";
|
||||
import Nostrich from "../nostrich.jpg";
|
||||
import { CSSProperties } from "react";
|
||||
import type { UserMetadata } from "Nostr";
|
||||
|
||||
|
||||
const Avatar = ({ user, ...rest }: { user?: UserMetadata, onClick?: () => void}) => {
|
||||
const avatarUrl = (user?.picture?.length ?? 0) === 0 ? Nostrich : user?.picture
|
||||
const backgroundImage = `url(${avatarUrl})`
|
||||
const domain = user?.nip05 && user.nip05.split('@')[1]
|
||||
const style = { '--img-url': backgroundImage } as CSSProperties
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
style={style}
|
||||
className="avatar"
|
||||
data-domain={domain?.toLowerCase()}
|
||||
>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
15
src/Element/Copy.css
Normal file
15
src/Element/Copy.css
Normal file
@ -0,0 +1,15 @@
|
||||
.copy {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.copy .body {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
background: var(--gray-secondary);
|
||||
color: var(--font-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: 10px;
|
||||
margin: 0 4px 0 0;
|
||||
}
|
27
src/Element/Copy.tsx
Normal file
27
src/Element/Copy.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import "./Copy.css";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useCopy } from "useCopy";
|
||||
|
||||
export interface CopyProps {
|
||||
text: string,
|
||||
maxSize?: number
|
||||
}
|
||||
export default function Copy({ text, maxSize = 32 }: CopyProps) {
|
||||
const { copy, copied, error } = 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>
|
||||
<FontAwesomeIcon
|
||||
icon={copied ? faCheck : faCopy}
|
||||
size="xs"
|
||||
style={{ color: copied ? 'var(--success)' : 'currentColor', marginRight: '2px' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
23
src/Element/DM.css
Normal file
23
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);
|
||||
}
|
53
src/Element/DM.tsx
Normal file
53
src/Element/DM.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import "./DM.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import Event from "Nostr/Event";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import Text from "Element/Text";
|
||||
import { setLastReadDm } from "Pages/MessagesPage";
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import { incDmInteraction } from "State/Login";
|
||||
|
||||
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 isMe = props.data.pubkey === pubKey;
|
||||
|
||||
async function decrypt() {
|
||||
let e = new Event(props.data);
|
||||
let 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={'Just now'} /></div>
|
||||
<div className="w-max">
|
||||
<Text content={content} tags={[]} users={new Map()} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
34
src/Element/FollowButton.tsx
Normal file
34
src/Element/FollowButton.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { HexKey } from "Nostr";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
export interface FollowButtonProps {
|
||||
pubkey: HexKey,
|
||||
className?: string,
|
||||
}
|
||||
export default function FollowButton(props: FollowButtonProps) {
|
||||
const pubkey = props.pubkey;
|
||||
const publiser = useEventPublisher();
|
||||
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
|
||||
const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button`
|
||||
const className = props.className ? `${baseClassName} ${props.className}` : `${baseClassName}`;
|
||||
|
||||
async function follow(pubkey: HexKey) {
|
||||
let ev = await publiser.addFollow(pubkey);
|
||||
publiser.broadcast(ev);
|
||||
}
|
||||
|
||||
async function unfollow(pubkey: HexKey) {
|
||||
let ev = await publiser.removeFollow(pubkey);
|
||||
publiser.broadcast(ev);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}>
|
||||
<FontAwesomeIcon icon={isFollowing ? faUserMinus : faUserPlus} size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
26
src/Element/FollowListBase.tsx
Normal file
26
src/Element/FollowListBase.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { HexKey } from "Nostr";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
|
||||
export interface FollowListBaseProps {
|
||||
pubkeys: HexKey[],
|
||||
title?: string
|
||||
}
|
||||
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) {
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
async function followAll() {
|
||||
let ev = await publisher.addFollow(pubkeys);
|
||||
publisher.broadcast(ev);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="f-grow">{title}</div>
|
||||
<div className="btn" onClick={() => followAll()}>Follow All</div>
|
||||
</div>
|
||||
{pubkeys?.map(a => <ProfilePreview pubkey={a} key={a} />)}
|
||||
</>
|
||||
)
|
||||
}
|
20
src/Element/FollowersList.tsx
Normal file
20
src/Element/FollowersList.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useMemo } from "react";
|
||||
import useFollowersFeed from "Feed/FollowersFeed";
|
||||
import { HexKey } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
|
||||
export interface FollowersListProps {
|
||||
pubkey: HexKey
|
||||
}
|
||||
|
||||
export default function FollowersList({ pubkey }: FollowersListProps) {
|
||||
const feed = useFollowersFeed(pubkey);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
let contactLists = feed?.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]);
|
||||
|
||||
return <FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
|
||||
}
|
19
src/Element/FollowsList.tsx
Normal file
19
src/Element/FollowsList.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useMemo } from "react";
|
||||
import useFollowsFeed from "Feed/FollowsFeed";
|
||||
import { HexKey } from "Nostr";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
import { getFollowers} from "Feed/FollowsFeed";
|
||||
|
||||
export interface FollowsListProps {
|
||||
pubkey: HexKey
|
||||
}
|
||||
|
||||
export default function FollowsList({ pubkey }: FollowsListProps) {
|
||||
const feed = useFollowsFeed(pubkey);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
return getFollowers(feed, pubkey);
|
||||
}, [feed]);
|
||||
|
||||
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
|
||||
}
|
6
src/Element/FollowsYou.css
Normal file
6
src/Element/FollowsYou.css
Normal file
@ -0,0 +1,6 @@
|
||||
.follows-you {
|
||||
color: var(--font-tertiary-color);
|
||||
font-size: var(--font-size-tiny);
|
||||
margin-left: .2em;
|
||||
font-weight: normal
|
||||
}
|
28
src/Element/FollowsYou.tsx
Normal file
28
src/Element/FollowsYou.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import "./FollowsYou.css";
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { HexKey } from "Nostr";
|
||||
import { RootState } from "State/Store";
|
||||
import useFollowsFeed from "Feed/FollowsFeed";
|
||||
import { getFollowers } from "Feed/FollowsFeed";
|
||||
|
||||
export interface FollowsYouProps {
|
||||
pubkey: HexKey
|
||||
}
|
||||
|
||||
export default function FollowsYou({ pubkey }: FollowsYouProps ) {
|
||||
const feed = useFollowsFeed(pubkey);
|
||||
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
return getFollowers(feed, pubkey);
|
||||
}, [feed]);
|
||||
|
||||
const followsMe = pubkeys.includes(loginPubKey!) ?? false ;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ followsMe ? <span className="follows-you">follows you</span> : null }
|
||||
</>
|
||||
)
|
||||
}
|
3
src/Element/Hashtag.css
Normal file
3
src/Element/Hashtag.css
Normal file
@ -0,0 +1,3 @@
|
||||
.hashtag {
|
||||
color: var(--highlight);
|
||||
}
|
12
src/Element/Hashtag.tsx
Normal file
12
src/Element/Hashtag.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
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
|
14
src/Element/Invoice.css
Normal file
14
src/Element/Invoice.css
Normal file
@ -0,0 +1,14 @@
|
||||
.note-invoice {
|
||||
border: 1px solid var(--font-secondary-color);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.note-invoice h2, .note-invoice h4, .note-invoice p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.note-invoice small {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
71
src/Element/Invoice.tsx
Normal file
71
src/Element/Invoice.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import "./Invoice.css";
|
||||
import { useState } from "react";
|
||||
// @ts-expect-error
|
||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||
import { useMemo } from "react";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import LNURLTip from "Element/LNURLTip";
|
||||
|
||||
export interface InvoiceProps {
|
||||
invoice: string
|
||||
}
|
||||
export default function Invoice(props: InvoiceProps) {
|
||||
const invoice = props.invoice;
|
||||
const [showInvoice, setShowInvoice] = useState(false);
|
||||
|
||||
const info = useMemo(() => {
|
||||
try {
|
||||
let parsed = invoiceDecode(invoice);
|
||||
|
||||
let amount = parseInt(parsed.sections.find((a: any) => a.name === "amount")?.value);
|
||||
let timestamp = parseInt(parsed.sections.find((a: any) => a.name === "timestamp")?.value);
|
||||
let expire = parseInt(parsed.sections.find((a: any) => a.name === "expiry")?.value);
|
||||
let description = parsed.sections.find((a: any) => a.name === "description")?.value;
|
||||
let ret = {
|
||||
amount: !isNaN(amount) ? (amount / 1000) : 0,
|
||||
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
||||
description,
|
||||
expired: false
|
||||
};
|
||||
if (ret.expire) {
|
||||
ret.expired = ret.expire < (new Date().getTime() / 1000);
|
||||
}
|
||||
return ret;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
function header() {
|
||||
if (info?.description?.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<h4>⚡️ Invoice for {info?.amount?.toLocaleString()} sats</h4>
|
||||
<p>{info?.description}</p>
|
||||
<LNURLTip invoice={invoice} show={showInvoice} onClose={() => setShowInvoice(false)} />
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<h4>⚡️ Invoice for {info?.amount?.toLocaleString()} sats</h4>
|
||||
<LNURLTip invoice={invoice} show={showInvoice} onClose={() => setShowInvoice(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="note-invoice flex">
|
||||
<div className="f-grow flex f-col">
|
||||
{header()}
|
||||
{info?.expire ? <small>{info?.expired ? "Expired" : "Expires"} <NoteTime from={info.expire * 1000} /></small> : null}
|
||||
</div>
|
||||
|
||||
{info?.expired ? <div className="btn">Expired</div> : <div className="btn" onClick={(e) => { e.stopPropagation(); setShowInvoice(true); }}>Pay</div>}
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
71
src/Element/LNURLTip.css
Normal file
71
src/Element/LNURLTip.css
Normal file
@ -0,0 +1,71 @@
|
||||
.lnurl-tip {
|
||||
background-color: var(--gray-tertiary);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
width: 500px;
|
||||
text-align: center;
|
||||
min-height: 10vh;
|
||||
}
|
||||
|
||||
.lnurl-tip .btn {
|
||||
background-color: inherit;
|
||||
width: 210px;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.lnurl-tip .btn:hover {
|
||||
background-color: var(--gray);
|
||||
}
|
||||
|
||||
.sat-amount {
|
||||
display: inline-block;
|
||||
background-color: var(--gray-secondary);
|
||||
color: var(--font-color);
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
margin: 2px 5px;
|
||||
}
|
||||
|
||||
.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 .pay-actions {
|
||||
margin: 10px auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media(max-width: 720px) {
|
||||
.lnurl-tip {
|
||||
width: 100vw;
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
254
src/Element/LNURLTip.tsx
Normal file
254
src/Element/LNURLTip.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
import "./LNURLTip.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { bech32ToText } from "Util";
|
||||
import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
import Copy from "Element/Copy";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webln?: {
|
||||
enabled: boolean,
|
||||
enable: () => Promise<void>,
|
||||
sendPayment: (pr: string) => Promise<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface LNURLService {
|
||||
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
|
||||
}
|
||||
|
||||
export default function LNURLTip(props: LNURLTipProps) {
|
||||
const onClose = props.onClose || (() => { });
|
||||
const service = props.svc;
|
||||
const show = props.show || false;
|
||||
const amounts = [50, 100, 500, 1_000, 5_000, 10_000, 50_000];
|
||||
const [payService, setPayService] = useState<LNURLService>();
|
||||
const [amount, setAmount] = useState<number>();
|
||||
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>();
|
||||
|
||||
useEffect(() => {
|
||||
if (show && !props.invoice) {
|
||||
loadService()
|
||||
.then(a => setPayService(a!))
|
||||
.catch(() => setError("Failed to load LNURL service"));
|
||||
} else {
|
||||
setPayService(undefined);
|
||||
setError(undefined);
|
||||
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
|
||||
setAmount(undefined);
|
||||
setComment(undefined);
|
||||
setSuccess(undefined);
|
||||
}
|
||||
}, [show, service]);
|
||||
|
||||
const serviceAmounts = useMemo(() => {
|
||||
if (payService) {
|
||||
let min = (payService.minSendable ?? 0) / 1000;
|
||||
let max = (payService.maxSendable ?? 0) / 1000;
|
||||
return amounts.filter(a => a >= min && a <= max);
|
||||
}
|
||||
return [];
|
||||
}, [payService]);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (payService) {
|
||||
let meta: string[][] = JSON.parse(payService.metadata);
|
||||
let desc = meta.find(a => a[0] === "text/plain");
|
||||
let image = meta.find(a => a[0] === "image/png;base64");
|
||||
return {
|
||||
description: desc ? desc[1] : null,
|
||||
image: image ? image[1] : null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [payService]);
|
||||
|
||||
const selectAmount = (a: number) => {
|
||||
setError(undefined);
|
||||
setInvoice(undefined);
|
||||
setAmount(a);
|
||||
};
|
||||
|
||||
async function fetchJson<T>(url: string) {
|
||||
let rsp = await fetch(url);
|
||||
if (rsp.ok) {
|
||||
let data: T = await rsp.json();
|
||||
console.log(data);
|
||||
setError(undefined);
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadService(): Promise<LNURLService | null> {
|
||||
if (service) {
|
||||
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
||||
if (isServiceUrl) {
|
||||
let serviceUrl = bech32ToText(service);
|
||||
return await fetchJson(serviceUrl);
|
||||
} else {
|
||||
let ns = service.split("@");
|
||||
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadInvoice() {
|
||||
if (!amount || !payService) return null;
|
||||
const url = `${payService.callback}?amount=${Math.floor(amount * 1000)}${comment ? `&comment=${encodeURIComponent(comment)}` : ""}`;
|
||||
try {
|
||||
let rsp = await fetch(url);
|
||||
if (rsp.ok) {
|
||||
let data = await rsp.json();
|
||||
console.log(data);
|
||||
if (data.status === "ERROR") {
|
||||
setError(data.reason);
|
||||
} else {
|
||||
setInvoice(data);
|
||||
setError("");
|
||||
}
|
||||
} else {
|
||||
setError("Failed to load invoice");
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to load invoice");
|
||||
}
|
||||
};
|
||||
|
||||
function custom() {
|
||||
let min = (payService?.minSendable ?? 0) / 1000;
|
||||
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
||||
return (
|
||||
<div className="flex mb10">
|
||||
<input type="number" min={min} max={max} className="f-grow mr10" value={customAmount} onChange={(e) => setCustomAmount(parseInt(e.target.value))} />
|
||||
<div className="btn" onClick={() => selectAmount(customAmount!)}>Confirm</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function payWebLN() {
|
||||
try {
|
||||
if (!window.webln!.enabled) {
|
||||
await window.webln!.enable();
|
||||
}
|
||||
let res = await window.webln!.sendPayment(invoice!.pr);
|
||||
console.log(res);
|
||||
setSuccess(invoice!.successAction || {});
|
||||
} catch (e: any) {
|
||||
setError(e.toString());
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
function webLn() {
|
||||
if ("webln" in window) {
|
||||
return (
|
||||
<div className="btn" onClick={() => payWebLN()}>Pay with WebLN</div>
|
||||
)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function invoiceForm() {
|
||||
if (invoice) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="f-ellipsis mb10">{metadata?.description ?? service}</div>
|
||||
<div className="flex">
|
||||
{(payService?.commentAllowed ?? 0) > 0 ?
|
||||
<input type="text" placeholder="Comment" className="mb10 f-grow" maxLength={payService?.commentAllowed} onChange={(e) => setComment(e.target.value)} /> : null}
|
||||
</div>
|
||||
<div className="mb10">
|
||||
{serviceAmounts.map(a => <span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
|
||||
{a.toLocaleString()}
|
||||
</span>)}
|
||||
{payService ?
|
||||
<span className={`sat-amount ${amount === -1 ? "active" : ""}`} onClick={() => selectAmount(-1)}>
|
||||
Custom
|
||||
</span> : null}
|
||||
</div>
|
||||
{amount === -1 ? custom() : null}
|
||||
{(amount ?? 0) > 0 ? <div className="btn mb10" onClick={() => loadInvoice()}>Get Invoice</div> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function payInvoice() {
|
||||
if (success) return null;
|
||||
const pr = invoice?.pr;
|
||||
return (
|
||||
<>
|
||||
<div className="invoice">
|
||||
<QrCode data={pr} link={`lightning:${pr}`} />
|
||||
<div className="actions">
|
||||
{pr && (
|
||||
<>
|
||||
<div className="copy-action">
|
||||
<Copy text={pr} maxSize={26} />
|
||||
</div>
|
||||
<div className="pay-actions">
|
||||
<div className="btn" onClick={() => window.open(`lightning:${pr}`)}>
|
||||
Open Wallet
|
||||
</div>
|
||||
<div>{webLn()}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function successAction() {
|
||||
if (!success) return null;
|
||||
return (
|
||||
<>
|
||||
<p>{success?.description ?? "Paid!"}</p>
|
||||
{success.url ? <a href={success.url} target="_blank">{success.url}</a> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!show) return null;
|
||||
return (
|
||||
<Modal onClose={() => onClose()}>
|
||||
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>{props.title || "⚡️ Send sats"}</h2>
|
||||
{invoiceForm()}
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
{payInvoice()}
|
||||
{successAction()}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
13
src/Element/LoadMore.tsx
Normal file
13
src/Element/LoadMore.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
export default function LoadMore({ onLoadMore }: { onLoadMore: () => void }) {
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
useEffect(() => {
|
||||
if (inView === true) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [inView]);
|
||||
return <div ref={ref} className="mb10">Loading...</div>;
|
||||
}
|
21
src/Element/Mention.tsx
Normal file
21
src/Element/Mention.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import { HexKey } from "Nostr";
|
||||
import { hexToBech32, profileLink } from "Util";
|
||||
|
||||
export default function Mention({ pubkey }: { pubkey: HexKey }) {
|
||||
const user = useProfile(pubkey)?.get(pubkey);
|
||||
|
||||
const name = useMemo(() => {
|
||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
||||
if ((user?.display_name?.length ?? 0) > 0) {
|
||||
name = user!.display_name!;
|
||||
} else if ((user?.name?.length ?? 0) > 0) {
|
||||
name = user!.name!;
|
||||
}
|
||||
return name;
|
||||
}, [user, pubkey]);
|
||||
|
||||
return <Link to={profileLink(pubkey)} onClick={(e) => e.stopPropagation()}>@{name}</Link>
|
||||
}
|
12
src/Element/Modal.css
Normal file
12
src/Element/Modal.css
Normal file
@ -0,0 +1,12 @@
|
||||
.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: 9999999;
|
||||
}
|
23
src/Element/Modal.tsx
Normal file
23
src/Element/Modal.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import "./Modal.css";
|
||||
import { useEffect } from "react"
|
||||
import * as React from "react";
|
||||
|
||||
export interface ModalProps {
|
||||
onClose?: () => void,
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
const onClose = props.onClose || (() => { });
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add("scroll-lock");
|
||||
return () => document.body.classList.remove("scroll-lock");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
61
src/Element/Nip05.css
Normal file
61
src/Element/Nip05.css
Normal file
@ -0,0 +1,61 @@
|
||||
.nip05 {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
margin: .2em;
|
||||
}
|
||||
|
||||
.nip05.failed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.nip05 .nick {
|
||||
color: var(--gray-light);
|
||||
font-weight: bold;
|
||||
margin-right: .2em;
|
||||
}
|
||||
|
||||
.nip05 .domain {
|
||||
color: var(--gray-superlight);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nip05 .text-gradient {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
background-color: var(--gray-superlight);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="snort.social"] {
|
||||
background-image: var(--snort-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostrplebs.com"] {
|
||||
background-image: var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostrpurple.com"] {
|
||||
background-image: var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostr.fan"] {
|
||||
background-image: var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostrich.zone"] {
|
||||
background-image: var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostriches.net"] {
|
||||
background-image: var(--nostrplebs-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="strike.army"] {
|
||||
background-image: var(--strike-army-gradient);
|
||||
}
|
||||
|
||||
.nip05 .badge {
|
||||
margin: .1em .2em;
|
||||
}
|
89
src/Element/Nip05.tsx
Normal file
89
src/Element/Nip05.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import './Nip05.css'
|
||||
import { HexKey } from "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) {
|
||||
const [name, domain] = nip05 ? nip05.split('@') : []
|
||||
const { isError, isSuccess, data } = useQuery(
|
||||
['nip05', nip05],
|
||||
() => 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
|
||||
}
|
||||
|
||||
const Nip05 = (props: Nip05Params) => {
|
||||
const [name, domain] = props.nip05 ? props.nip05.split('@') : []
|
||||
const isDefaultUser = name === '_'
|
||||
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05)
|
||||
|
||||
return (
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}>
|
||||
{!isDefaultUser && (
|
||||
<div className="nick">
|
||||
{name}
|
||||
</div>
|
||||
)}
|
||||
<div className={`domain text-gradient`} data-domain={domain?.toLowerCase()}>
|
||||
{domain}
|
||||
</div>
|
||||
<span className="badge">
|
||||
{!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
|
193
src/Element/Nip5Service.tsx
Normal file
193
src/Element/Nip5Service.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ServiceProvider,
|
||||
ServiceConfig,
|
||||
ServiceError,
|
||||
HandleAvailability,
|
||||
ServiceErrorCode,
|
||||
HandleRegisterResponse,
|
||||
CheckRegisterResponse
|
||||
} from "Nip05/ServiceProvider";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import LNURLTip from "Element/LNURLTip";
|
||||
import Copy from "Element/Copy";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { hexToBech32 } from "Util";
|
||||
import { UserMetadata } from "Nostr";
|
||||
|
||||
type Nip05ServiceProps = {
|
||||
name: string,
|
||||
service: URL | string,
|
||||
about: JSX.Element,
|
||||
link: string,
|
||||
supportLink: string
|
||||
};
|
||||
|
||||
type ReduxStore = any;
|
||||
|
||||
export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
const navigate = useNavigate();
|
||||
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
|
||||
const user = useProfile(pubkey);
|
||||
const publisher = useEventPublisher();
|
||||
const svc = new ServiceProvider(props.service);
|
||||
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
|
||||
const [error, setError] = useState<ServiceError>();
|
||||
const [handle, setHandle] = useState<string>("");
|
||||
const [domain, setDomain] = useState<string>("");
|
||||
const [availabilityResponse, setAvailabilityResponse] = useState<HandleAvailability>();
|
||||
const [registerResponse, setRegisterResponse] = useState<HandleRegisterResponse>();
|
||||
const [showInvoice, setShowInvoice] = useState<boolean>(false);
|
||||
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
|
||||
|
||||
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain]);
|
||||
|
||||
useEffect(() => {
|
||||
svc.GetConfig()
|
||||
.then(a => {
|
||||
if ('error' in a) {
|
||||
setError(a as ServiceError)
|
||||
} else {
|
||||
let svc = a as ServiceConfig;
|
||||
setServiceConfig(svc);
|
||||
let defaultDomain = svc.domains.find(a => a.default)?.name || svc.domains[0].name;
|
||||
setDomain(defaultDomain);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [props]);
|
||||
|
||||
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;
|
||||
}
|
||||
let rx = new RegExp(domainConfig?.regex[0] ?? "", domainConfig?.regex[1] ?? "");
|
||||
if (!rx.test(handle)) {
|
||||
setAvailabilityResponse({ available: false, why: "REGEX" });
|
||||
return;
|
||||
}
|
||||
let t = setTimeout(() => {
|
||||
svc.CheckAvailable(handle, domain)
|
||||
.then(a => {
|
||||
if ('error' in a) {
|
||||
setError(a as ServiceError);
|
||||
} else {
|
||||
setAvailabilityResponse(a as HandleAvailability);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, 500);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [handle, domain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registerResponse && showInvoice) {
|
||||
let t = setInterval(async () => {
|
||||
let status = await svc.CheckRegistration(registerResponse.token);
|
||||
if ('error' in status) {
|
||||
setError(status);
|
||||
setRegisterResponse(undefined);
|
||||
setShowInvoice(false);
|
||||
} else {
|
||||
let result: CheckRegisterResponse = status;
|
||||
if (result.available && result.paid) {
|
||||
setShowInvoice(false);
|
||||
setRegisterStatus(status);
|
||||
setRegisterResponse(undefined);
|
||||
setError(undefined);
|
||||
}
|
||||
}
|
||||
}, 2_000);
|
||||
return () => clearInterval(t);
|
||||
}
|
||||
}, [registerResponse, showInvoice])
|
||||
|
||||
function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
|
||||
let whyMap = new Map([
|
||||
["TOO_SHORT", "name too short"],
|
||||
["TOO_LONG", "name too long"],
|
||||
["REGEX", "name has disallowed characters"],
|
||||
["REGISTERED", "name is registered"],
|
||||
["DISALLOWED_null", "name is blocked"],
|
||||
["DISALLOWED_later", "name will be available later"],
|
||||
]);
|
||||
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
|
||||
}
|
||||
|
||||
async function startBuy(handle: string, domain: string) {
|
||||
if (registerResponse) {
|
||||
setShowInvoice(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let 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) {
|
||||
let newProfile = {
|
||||
...user,
|
||||
nip05: `${handle}@${domain}`
|
||||
} as UserMetadata;
|
||||
let ev = await publisher.metadata(newProfile);
|
||||
publisher.broadcast(ev);
|
||||
navigate("/settings");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{props.name}</h3>
|
||||
{props.about}
|
||||
<p>Find out more info about {props.name} at <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="Handle" value={handle} onChange={(e) => setHandle(e.target.value)} />
|
||||
@
|
||||
<select value={domain} onChange={(e) => setDomain(e.target.value)}>
|
||||
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>}
|
||||
{availabilityResponse?.available && !registerStatus && <div className="flex">
|
||||
<div className="mr10">
|
||||
{availabilityResponse.quote?.price.toLocaleString()} sats<br />
|
||||
<small>{availabilityResponse.quote?.data.type}</small>
|
||||
</div>
|
||||
<input type="text" className="f-grow mr10" placeholder="pubkey" value={hexToBech32("npub", pubkey)} disabled />
|
||||
<AsyncButton onClick={() => startBuy(handle, domain)}>Buy Now</AsyncButton>
|
||||
</div>}
|
||||
{availabilityResponse?.available === false && !registerStatus && <div className="flex">
|
||||
<b className="error">Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)}</b>
|
||||
</div>}
|
||||
<LNURLTip invoice={registerResponse?.invoice} show={showInvoice} onClose={() => setShowInvoice(false)} title={`Buying ${handle}@${domain}`} />
|
||||
{registerStatus?.paid && <div className="flex f-col">
|
||||
<h4>Order Paid!</h4>
|
||||
<p>Your new NIP-05 handle is: <code>{handle}@{domain}</code></p>
|
||||
<h3>Account Support</h3>
|
||||
<p>Please make sure to save the following password in order to manage your handle in the future</p>
|
||||
<Copy text={registerStatus.password} />
|
||||
<p>Go to <a href={props.supportLink} target="_blank" rel="noreferrer">account page</a></p>
|
||||
<h4>Activate Now</h4>
|
||||
<AsyncButton onClick={() => updateProfile(handle, domain)}>Add to Profile</AsyncButton>
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
156
src/Element/Note.css
Normal file
156
src/Element/Note.css
Normal file
@ -0,0 +1,156 @@
|
||||
.note {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--note-bg);
|
||||
padding: 12px;
|
||||
min-height: 110px;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.note { margin-bottom: 24px; padding: 24px; }
|
||||
}
|
||||
|
||||
.note.thread {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.note .header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.note > .header .reply {
|
||||
font-size: var(--font-size-tiny);
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.note > .header > .info {
|
||||
font-size: var(--font-size);
|
||||
white-space: nowrap;
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.note > .body {
|
||||
margin-top: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.note > .header img:hover, .note > .header .name > .reply:hover, .note .body:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note > .footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.note > .note-creator {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.note > .footer { margin-top: 24px; }
|
||||
.note > .note-creator { margin-top: 24px; }
|
||||
}
|
||||
|
||||
|
||||
.thread.note {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.thread.note, .indented .note {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.indented .note {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.indented {
|
||||
border-left: 3px solid var(--gray-tertiary);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.note:last-child {
|
||||
border-bottom-right-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.indented .note.active:last-child {
|
||||
border-bottom-right-radius: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.indented > .indented .note:last-child {
|
||||
border-bottom-right-radius: 0px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.indented .active {
|
||||
background-color: var(--gray-tertiary);
|
||||
margin-left: -5px;
|
||||
border-left: 3px solid var(--highlight);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.reaction-pill {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0px 10px;
|
||||
user-select: none;
|
||||
color: var(--font-secondary-color);
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.trash-icon {
|
||||
color: var(--error);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.reaction-pill .reaction-pill-number {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.reaction-pill.reacted {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.reaction-pill:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note.active > .header .reply {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
.note.active > .header > .info {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
.note.active > .footer > .reaction-pill {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.indented .active {
|
||||
background-color: var(--gray-secondary);
|
||||
}
|
||||
.note.active > .header .reply {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
.note.active > .header > .info {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
.note.active > .footer > .reaction-pill {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
.note.active > .footer > .reaction-pill.reacted {
|
||||
color: var(--highlight);
|
||||
}
|
||||
}
|
122
src/Element/Note.tsx
Normal file
122
src/Element/Note.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import "./Note.css";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Text from "Element/Text";
|
||||
import { eventLink, getReactions, hexToBech32 } from "Util";
|
||||
import NoteFooter from "Element/NoteFooter";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import { TaggedRawEvent, u256 } from "Nostr";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
export interface NoteProps {
|
||||
data?: TaggedRawEvent,
|
||||
isThread?: boolean,
|
||||
related: TaggedRawEvent[],
|
||||
highlight?: boolean,
|
||||
options?: {
|
||||
showHeader?: boolean,
|
||||
showTime?: boolean,
|
||||
showFooter?: boolean
|
||||
},
|
||||
["data-ev"]?: NEvent
|
||||
}
|
||||
|
||||
export default function Note(props: NoteProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props
|
||||
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||
const users = useProfile(pubKeys);
|
||||
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
|
||||
const options = {
|
||||
showHeader: true,
|
||||
showTime: true,
|
||||
showFooter: true,
|
||||
...opt
|
||||
};
|
||||
|
||||
const transformBody = useCallback(() => {
|
||||
let body = ev?.Content ?? "";
|
||||
if (deletions?.length > 0) {
|
||||
return (<b className="error">Deleted</b>);
|
||||
}
|
||||
return <Text content={body} tags={ev.Tags} users={users || new Map()} />;
|
||||
}, [ev]);
|
||||
|
||||
function goToEvent(e: any, id: u256) {
|
||||
if (!window.location.pathname.startsWith("/e/")) {
|
||||
e.stopPropagation();
|
||||
navigate(eventLink(id));
|
||||
}
|
||||
}
|
||||
|
||||
function replyTag() {
|
||||
if (ev.Thread === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxMentions = 2;
|
||||
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||
let mentions: string[] = [];
|
||||
for (let pk of ev.Thread?.PubKeys) {
|
||||
let u = users?.get(pk);
|
||||
if (u) {
|
||||
mentions.push(u.name ?? hexToBech32("npub", pk).substring(0, 12));
|
||||
} else {
|
||||
mentions.push(hexToBech32("npub", pk).substring(0, 12));
|
||||
}
|
||||
}
|
||||
mentions.sort((a, b) => a.startsWith("npub") ? 1 : -1);
|
||||
let othersLength = mentions.length - maxMentions
|
||||
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
|
||||
return (
|
||||
<div className="reply">
|
||||
{(pubMentions?.length ?? 0) > 0 ? pubMentions : replyId ? hexToBech32("note", replyId)?.substring(0, 12) : ""}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (ev.Kind !== EventKind.TextNote) {
|
||||
return (
|
||||
<>
|
||||
<h4>Unknown event kind: {ev.Kind}</h4>
|
||||
<pre>
|
||||
{JSON.stringify(ev.ToObject(), undefined, ' ')}
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function content() {
|
||||
if (!inView) return null;
|
||||
return (
|
||||
<>
|
||||
{options.showHeader ?
|
||||
<div className="header flex">
|
||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||
{options.showTime ?
|
||||
<div className="info">
|
||||
<NoteTime from={ev.CreatedAt * 1000} />
|
||||
</div> : null}
|
||||
</div> : null}
|
||||
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
||||
{transformBody()}
|
||||
</div>
|
||||
{options.showFooter ? <NoteFooter ev={ev} related={related} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`note${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}>
|
||||
{content()}
|
||||
</div>
|
||||
)
|
||||
}
|
49
src/Element/NoteCreator.css
Normal file
49
src/Element/NoteCreator.css
Normal file
@ -0,0 +1,49 @@
|
||||
.note-creator {
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--gray);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note-reply {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.note-creator textarea {
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 40px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
max-width: -webkit-fill-available;
|
||||
max-width: -moz-available;
|
||||
max-width: fill-available;
|
||||
min-width: 100%;
|
||||
min-width: -webkit-fill-available;
|
||||
min-width: -moz-available;
|
||||
min-width: fill-available;
|
||||
}
|
||||
|
||||
.note-creator .actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.note-creator .attachment {
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.note-creator .attachment .error {
|
||||
font-weight: normal;
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.note-creator .btn {
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
background-color: var(--gray-secondary);
|
||||
color: var(--gray-superlight);
|
||||
border-color: var(--gray-superlight);
|
||||
}
|
103
src/Element/NoteCreator.tsx
Normal file
103
src/Element/NoteCreator.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPaperclip } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import "./NoteCreator.css";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { openFile } from "Util";
|
||||
import VoidUpload from "Feed/VoidUpload";
|
||||
import { FileExtensionRegex } from "Const";
|
||||
import Textarea from "Element/Textarea";
|
||||
import Event, { default as NEvent } from "Nostr/Event";
|
||||
|
||||
export interface NoteCreatorProps {
|
||||
replyTo?: NEvent,
|
||||
onSend?: Function,
|
||||
show: boolean,
|
||||
autoFocus: boolean
|
||||
}
|
||||
|
||||
export function NoteCreator(props: NoteCreatorProps) {
|
||||
const publisher = useEventPublisher();
|
||||
const [note, setNote] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [active, setActive] = useState<boolean>(false);
|
||||
|
||||
async function sendNote() {
|
||||
if (note) {
|
||||
let ev = props.replyTo ? await publisher.reply(props.replyTo, note) : await publisher.note(note);
|
||||
console.debug("Sending note: ", ev);
|
||||
publisher.broadcast(ev);
|
||||
setNote("");
|
||||
if (typeof props.onSend === "function") {
|
||||
props.onSend();
|
||||
}
|
||||
setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachFile() {
|
||||
try {
|
||||
let file = await openFile();
|
||||
if (file) {
|
||||
let rx = await VoidUpload(file, file.name);
|
||||
if (rx?.ok && rx?.file) {
|
||||
let ext = file.name.match(FileExtensionRegex);
|
||||
|
||||
// extension tricks note parser to embed the content
|
||||
let url = rx.file.meta?.url ?? `https://void.cat/d/${rx.file.id}${ext ? `.${ext[1]}` : ""}`;
|
||||
|
||||
setNote(n => `${n}\n${url}`);
|
||||
} else if (rx?.errorMessage) {
|
||||
setError(rx.errorMessage);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setError(error?.message)
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(ev: any) {
|
||||
const { value } = ev.target
|
||||
setNote(value)
|
||||
if (value) {
|
||||
setActive(true)
|
||||
} else {
|
||||
setActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
|
||||
ev.stopPropagation();
|
||||
sendNote().catch(console.warn);
|
||||
}
|
||||
|
||||
if (!props.show) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={`flex note-creator ${props.replyTo ? 'note-reply' : ''}`}>
|
||||
<div className="flex f-col mr10 f-grow">
|
||||
<Textarea
|
||||
autoFocus={props.autoFocus}
|
||||
className={`textarea ${active ? "textarea--focused" : ""}`}
|
||||
onChange={onChange}
|
||||
value={note}
|
||||
onFocus={() => setActive(true)}
|
||||
/>
|
||||
{active && note && (
|
||||
<div className="actions flex f-row">
|
||||
<div className="attachment flex f-row">
|
||||
{(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null}
|
||||
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={onSubmit}>
|
||||
{props.replyTo ? 'Reply' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
160
src/Element/NoteFooter.tsx
Normal file
160
src/Element/NoteFooter.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { faHeart, faReply, faThumbsDown, faTrash, faBolt, faRepeat } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { getReactions, normalizeReaction, Reaction } from "Util";
|
||||
import { NoteCreator } from "Element/NoteCreator";
|
||||
import LNURLTip from "Element/LNURLTip";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
|
||||
export interface NoteFooterProps {
|
||||
related: TaggedRawEvent[],
|
||||
ev: NEvent
|
||||
}
|
||||
|
||||
export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { related, ev } = props;
|
||||
|
||||
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
|
||||
const publisher = useEventPublisher();
|
||||
const [reply, setReply] = useState(false);
|
||||
const [tip, setTip] = useState(false);
|
||||
const isMine = ev.RootPubKey === login;
|
||||
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related]);
|
||||
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related]);
|
||||
const groupReactions = useMemo(() => {
|
||||
return reactions?.reduce((acc, { content }) => {
|
||||
let r = normalizeReaction(content);
|
||||
const amount = acc[r] || 0
|
||||
return { ...acc, [r]: amount + 1 }
|
||||
}, {
|
||||
[Reaction.Positive]: 0,
|
||||
[Reaction.Negative]: 0
|
||||
});
|
||||
}, [reactions]);
|
||||
|
||||
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)) {
|
||||
let evLike = await publisher.react(ev, content);
|
||||
publisher.broadcast(evLike);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (window.confirm(`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`)) {
|
||||
let evDelete = await publisher.delete(ev.Id);
|
||||
publisher.broadcast(evDelete);
|
||||
}
|
||||
}
|
||||
|
||||
async function repost() {
|
||||
if (!hasReposted()) {
|
||||
let evRepost = await publisher.repost(ev);
|
||||
publisher.broadcast(evRepost);
|
||||
}
|
||||
}
|
||||
|
||||
function tipButton() {
|
||||
let service = author?.lud16 || author?.lud06;
|
||||
if (service) {
|
||||
return (
|
||||
<>
|
||||
<div className="reaction-pill" onClick={(e) => setTip(true)}>
|
||||
<div className="reaction-pill-icon">
|
||||
<FontAwesomeIcon icon={faBolt} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function reactionIcon(content: string, reacted: boolean) {
|
||||
switch (content) {
|
||||
case Reaction.Positive: {
|
||||
return <FontAwesomeIcon icon={faHeart} />;
|
||||
}
|
||||
case Reaction.Negative: {
|
||||
return <FontAwesomeIcon icon={faThumbsDown} />;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function repostIcon() {
|
||||
return (
|
||||
<div className={`reaction-pill ${hasReposted() ? 'reacted' : ''}`} onClick={() => repost()}>
|
||||
<div className="reaction-pill-icon">
|
||||
<FontAwesomeIcon icon={faRepeat} />
|
||||
</div>
|
||||
{reposts.length > 0 && (
|
||||
<div className="reaction-pill-number">
|
||||
{formatShort(reposts.length)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="footer">
|
||||
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
|
||||
<div className="reaction-pill-icon">
|
||||
<FontAwesomeIcon icon={faReply} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={(e) => react("+")}>
|
||||
<div className="reaction-pill-icon">
|
||||
<FontAwesomeIcon icon={faHeart} />
|
||||
</div>
|
||||
<div className="reaction-pill-number">
|
||||
{formatShort(groupReactions[Reaction.Positive])}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`reaction-pill ${hasReacted('-') ? 'reacted' : ''}`} onClick={(e) => react("-")}>
|
||||
<div className="reaction-pill-icon">
|
||||
<FontAwesomeIcon icon={faThumbsDown} />
|
||||
</div>
|
||||
<div className="reaction-pill-number">
|
||||
{formatShort(groupReactions[Reaction.Negative])}
|
||||
</div>
|
||||
</div>
|
||||
{repostIcon()}
|
||||
{tipButton()}
|
||||
{isMine && (
|
||||
<div className="reaction-pill trash-icon">
|
||||
<div className="reaction-pill-icon">
|
||||
<FontAwesomeIcon icon={faTrash} onClick={(e) => deleteEvent()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NoteCreator
|
||||
autoFocus={true}
|
||||
replyTo={ev}
|
||||
onSend={() => setReply(false)}
|
||||
show={reply}
|
||||
/>
|
||||
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
|
||||
</>
|
||||
)
|
||||
}
|
17
src/Element/NoteGhost.tsx
Normal file
17
src/Element/NoteGhost.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import "./Note.css";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
|
||||
export default function NoteGhost(props: any) {
|
||||
return (
|
||||
<div className="note">
|
||||
<div className="header">
|
||||
<ProfileImage pubkey="" />
|
||||
</div>
|
||||
<div className="body">
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="footer">
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/Element/NoteReaction.css
Normal file
24
src/Element/NoteReaction.css
Normal file
@ -0,0 +1,24 @@
|
||||
.reaction {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reaction > .note {
|
||||
margin: 10px 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
82
src/Element/NoteReaction.tsx
Normal file
82
src/Element/NoteReaction.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import "./NoteReaction.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import Note from "Element/Note";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
import { eventLink, hexToBech32 } from "Util";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import { RawEvent, TaggedRawEvent } from "Nostr";
|
||||
|
||||
export interface NoteReactionProps {
|
||||
data?: TaggedRawEvent,
|
||||
["data-ev"]?: NEvent,
|
||||
root?: TaggedRawEvent
|
||||
}
|
||||
export default function NoteReaction(props: NoteReactionProps) {
|
||||
const ev = useMemo(() => props["data-ev"] || new NEvent(props.data), [props.data, props["data-ev"]])
|
||||
|
||||
const refEvent = useMemo(() => {
|
||||
if (ev) {
|
||||
let 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;
|
||||
}
|
||||
|
||||
function mapReaction(c: string) {
|
||||
switch (c) {
|
||||
case "+": return "❤️";
|
||||
case "-": return "👎";
|
||||
default: {
|
||||
if (c.length === 0) {
|
||||
return "❤️";
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some clients embed the reposted note in the content
|
||||
*/
|
||||
function extractRoot() {
|
||||
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0) {
|
||||
try {
|
||||
let 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 opt = {
|
||||
showHeader: ev?.Kind === EventKind.Repost,
|
||||
showFooter: ev?.Kind === EventKind.Repost,
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
50
src/Element/NoteTime.tsx
Normal file
50
src/Element/NoteTime.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
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;
|
||||
|
||||
function calcTime() {
|
||||
let fromDate = new Date(from);
|
||||
let ago = (new Date().getTime()) - from;
|
||||
let 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 {
|
||||
let mins = Math.floor(absAgo / MinuteInMs);
|
||||
if(ago < 0) {
|
||||
return `in ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTime(calcTime());
|
||||
let t = setInterval(() => {
|
||||
setTime(s => {
|
||||
let newTime = calcTime();
|
||||
if (newTime !== s) {
|
||||
return newTime;
|
||||
}
|
||||
return s;
|
||||
})
|
||||
}, MinuteInMs);
|
||||
return () => clearInterval(t);
|
||||
}, [from]);
|
||||
|
||||
return <>{time}</>
|
||||
}
|
43
src/Element/NoteToSelf.css
Normal file
43
src/Element/NoteToSelf.css
Normal file
@ -0,0 +1,43 @@
|
||||
.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 a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--gray-superlight);
|
||||
}
|
||||
|
||||
.nts .name {
|
||||
margin-top: -.2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nts .nip05 {
|
||||
margin: 0;
|
||||
margin-top: -.2em;
|
||||
}
|
56
src/Element/NoteToSelf.tsx
Normal file
56
src/Element/NoteToSelf.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import "./NoteToSelf.css";
|
||||
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons"
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import Nip05 from "Element/Nip05";
|
||||
import { profileLink } from "Util";
|
||||
|
||||
export interface NoteToSelfProps {
|
||||
pubkey: string,
|
||||
clickable?: boolean
|
||||
className?: string,
|
||||
link?: string
|
||||
};
|
||||
|
||||
function NoteLabel({pubkey, link}:NoteToSelfProps) {
|
||||
const user = useProfile(pubkey)?.get(pubkey);
|
||||
return (
|
||||
<div>
|
||||
Note to Self <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>
|
||||
)
|
||||
}
|
35
src/Element/ProfileImage.css
Normal file
35
src/Element/ProfileImage.css
Normal file
@ -0,0 +1,35 @@
|
||||
.pfp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pfp .avatar-wrapper {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pfp .avatar {
|
||||
border-width: 1px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pfp a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pfp a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--gray-superlight);
|
||||
}
|
||||
|
||||
.pfp .profile-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pfp .nip05 {
|
||||
margin: 0;
|
||||
margin-top: -.2em;
|
||||
}
|
55
src/Element/ProfileImage.tsx
Normal file
55
src/Element/ProfileImage.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import "./ProfileImage.css";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import { hexToBech32, profileLink } from "Util";
|
||||
import Avatar from "Element/Avatar"
|
||||
import Nip05 from "Element/Nip05";
|
||||
import { HexKey } from "Nostr";
|
||||
import { MetadataCache } from "Db/User";
|
||||
|
||||
export interface ProfileImageProps {
|
||||
pubkey: HexKey,
|
||||
subHeader?: JSX.Element,
|
||||
showUsername?: boolean,
|
||||
className?: string,
|
||||
link?: string
|
||||
};
|
||||
|
||||
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) {
|
||||
const navigate = useNavigate();
|
||||
const user = useProfile(pubkey)?.get(pubkey);
|
||||
|
||||
const name = useMemo(() => {
|
||||
return getDisplayName(user, pubkey);
|
||||
}, [user, pubkey]);
|
||||
|
||||
return (
|
||||
<div className={`pfp${className ? ` ${className}` : ""}`}>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
||||
</div>
|
||||
{showUsername && (<div className="f-grow">
|
||||
<Link key={pubkey} to={link ?? profileLink(pubkey)}>
|
||||
<div className="profile-name">
|
||||
<div>{name}</div>
|
||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||
</div>
|
||||
</Link>
|
||||
{subHeader ? <>{subHeader}</> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
|
||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
||||
if ((user?.display_name?.length ?? 0) > 0) {
|
||||
name = user!.display_name!;
|
||||
} else if ((user?.name?.length ?? 0) > 0) {
|
||||
name = user!.name!;
|
||||
}
|
||||
return name;
|
||||
}
|
16
src/Element/ProfilePreview.css
Normal file
16
src/Element/ProfilePreview.css
Normal file
@ -0,0 +1,16 @@
|
||||
.profile-preview {
|
||||
display: flex;
|
||||
padding: 5px 0;
|
||||
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);
|
||||
}
|
37
src/Element/ProfilePreview.tsx
Normal file
37
src/Element/ProfilePreview.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import "./ProfilePreview.css";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import FollowButton from "Element/FollowButton";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import { HexKey } from "Nostr";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
export interface ProfilePreviewProps {
|
||||
pubkey: HexKey,
|
||||
options?: {
|
||||
about?: boolean
|
||||
},
|
||||
actions?: ReactNode
|
||||
}
|
||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
const pubkey = props.pubkey;
|
||||
const user = useProfile(pubkey)?.get(pubkey);
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
const options = {
|
||||
about: true,
|
||||
...props.options
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profile-preview" ref={ref}>
|
||||
{inView && <>
|
||||
<ProfileImage pubkey={pubkey} subHeader=
|
||||
{options.about ? <div className="f-ellipsis about">
|
||||
{user?.about}
|
||||
</div> : undefined} />
|
||||
{props.actions ?? <FollowButton pubkey={pubkey} className="ml5" />}
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
51
src/Element/QrCode.tsx
Normal file
51
src/Element/QrCode.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface QrCodeProps {
|
||||
data?: string,
|
||||
link?: string,
|
||||
avatar?: string,
|
||||
height?: number,
|
||||
width?: number
|
||||
}
|
||||
|
||||
export default function QrCode(props: QrCodeProps) {
|
||||
const qrRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
|
||||
let 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 (e) {
|
||||
let 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" ref={qrRef}></div>
|
||||
);
|
||||
}
|
42
src/Element/Relay.css
Normal file
42
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: .5em;
|
||||
padding: 2px 10px;
|
||||
background-color: var(--gray);
|
||||
border-radius: 10px;
|
||||
}
|
89
src/Element/Relay.tsx
Normal file
89
src/Element/Relay.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import "./Relay.css"
|
||||
|
||||
import { faPlug, faTrash, faSquareCheck, faSquareXmark, faWifi, faUpload, faDownload, faPlugCircleXmark, faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
|
||||
import useRelayState from "Feed/RelayState";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { removeRelay, setRelays } from "State/Login";
|
||||
import { RootState } from "State/Store";
|
||||
import { RelaySettings } from "Nostr/Connection";
|
||||
|
||||
export interface RelayProps {
|
||||
addr: string
|
||||
}
|
||||
|
||||
export default function Relay(props: RelayProps) {
|
||||
const dispatch = useDispatch();
|
||||
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]);
|
||||
const [showExtra, setShowExtra] = useState(false);
|
||||
|
||||
function configure(o: RelaySettings) {
|
||||
dispatch(setRelays({
|
||||
relays: {
|
||||
...allRelaySettings,
|
||||
[props.addr]: o
|
||||
},
|
||||
createdAt: Math.floor(new Date().getTime() / 1000)
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
let 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">
|
||||
Write
|
||||
<span className="checkmark" onClick={() => configure({ write: !relaySettings.write, read: relaySettings.read })}>
|
||||
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="f-1">
|
||||
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 ? `${(latency / 1000).toFixed(0)} secs` : `${latency.toLocaleString()} ms`}
|
||||
|
||||
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
|
||||
</div>
|
||||
<div>
|
||||
<span className="icon-btn" onClick={() => setShowExtra(s => !s)}>
|
||||
<FontAwesomeIcon icon={faEllipsisVertical} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showExtra ? <div className="flex relay-extra w-max">
|
||||
<div className="f-1">
|
||||
<FontAwesomeIcon icon={faUpload} /> {state?.events.send}
|
||||
</div>
|
||||
<div className="f-1">
|
||||
<FontAwesomeIcon icon={faDownload} /> {state?.events.received}
|
||||
</div>
|
||||
|
||||
<div className="f-1">
|
||||
Delete
|
||||
<span className="icon-btn" onClick={() => dispatch(removeRelay(props.addr))}>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</span>
|
||||
</div>
|
||||
</div> : null}
|
||||
</>
|
||||
)
|
||||
}
|
58
src/Element/Text.css
Normal file
58
src/Element/Text.css
Normal file
@ -0,0 +1,58 @@
|
||||
.text a {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.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 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 {
|
||||
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;
|
||||
}
|
195
src/Element/Text.tsx
Normal file
195
src/Element/Text.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import './Text.css'
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { TwitterTweetEmbed } from "react-twitter-embed";
|
||||
|
||||
import { UrlRegex, FileExtensionRegex, MentionRegex, InvoiceRegex, YoutubeUrlRegex, TweetUrlRegex, HashtagRegex } from "Const";
|
||||
import { eventLink, hexToBech32 } from "Util";
|
||||
import Invoice from "Element/Invoice";
|
||||
import Hashtag from "Element/Hashtag";
|
||||
|
||||
import Tag from "Nostr/Tag";
|
||||
import { MetadataCache } from "Db/User";
|
||||
import Mention from "Element/Mention";
|
||||
|
||||
function transformHttpLink(a: string) {
|
||||
try {
|
||||
const url = new URL(a);
|
||||
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
|
||||
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
|
||||
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
if (extension) {
|
||||
switch (extension) {
|
||||
case "gif":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "bmp":
|
||||
case "webp": {
|
||||
return <img key={url.toString()} src={url.toString()} />;
|
||||
}
|
||||
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 {
|
||||
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
|
||||
}
|
||||
|
||||
function extractLinks(fragments: Fragment[]) {
|
||||
return fragments.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(UrlRegex).map(a => {
|
||||
if (a.startsWith("http")) {
|
||||
return transformHttpLink(a)
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
return f;
|
||||
}).flat();
|
||||
}
|
||||
|
||||
function extractMentions(fragments: Fragment[], tags: Tag[], users: Map<string, MetadataCache>) {
|
||||
return fragments.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(MentionRegex).map((match) => {
|
||||
let matchTag = match.match(/#\[(\d+)\]/);
|
||||
if (matchTag && matchTag.length === 2) {
|
||||
let idx = parseInt(matchTag[1]);
|
||||
let ref = tags?.find(a => a.Index === idx);
|
||||
if (ref) {
|
||||
switch (ref.Key) {
|
||||
case "p": {
|
||||
return <Mention pubkey={ref.PubKey!} />
|
||||
}
|
||||
case "e": {
|
||||
let 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 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 tag={i.substring(1)} />
|
||||
} else {
|
||||
return i;
|
||||
}
|
||||
});
|
||||
}
|
||||
return f;
|
||||
}).flat();
|
||||
}
|
||||
|
||||
function transformLi({ body, tags, users }: TextFragment) {
|
||||
let fragments = transformText({ body, tags, users })
|
||||
return <li>{fragments}</li>
|
||||
}
|
||||
|
||||
function transformParagraph({ body, tags, users }: TextFragment) {
|
||||
const fragments = transformText({ body, tags, users })
|
||||
if (fragments.every(f => typeof f === 'string')) {
|
||||
return <p>{fragments}</p>
|
||||
}
|
||||
return <>{fragments}</>
|
||||
}
|
||||
|
||||
function transformText({ body, tags, users }: TextFragment) {
|
||||
if (body === undefined) {
|
||||
debugger;
|
||||
}
|
||||
let fragments = extractMentions(body, tags, users);
|
||||
fragments = extractLinks(fragments);
|
||||
fragments = extractInvoices(fragments);
|
||||
fragments = extractHashtags(fragments);
|
||||
return fragments;
|
||||
}
|
||||
|
||||
export type Fragment = string | JSX.Element;
|
||||
|
||||
export interface TextFragment {
|
||||
body: Fragment[],
|
||||
tags: Tag[],
|
||||
users: Map<string, MetadataCache>
|
||||
}
|
||||
|
||||
export interface TextProps {
|
||||
content: string,
|
||||
tags: Tag[],
|
||||
users: Map<string, MetadataCache>
|
||||
}
|
||||
|
||||
export default function Text({ content, tags, users }: TextProps) {
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
p: (x: any) => transformParagraph({ body: x.children ?? [], tags, users }),
|
||||
a: (x: any) => transformHttpLink(x.href),
|
||||
li: (x: any) => transformLi({ body: x.children ?? [], tags, users }),
|
||||
};
|
||||
}, [content]);
|
||||
return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown>
|
||||
}
|
54
src/Element/Textarea.css
Normal file
54
src/Element/Textarea.css
Normal file
@ -0,0 +1,54 @@
|
||||
.user-item, .emoji-item {
|
||||
background: var(--gray);
|
||||
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;
|
||||
}
|
||||
|
||||
.nip05 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.emoji-item .emoji {
|
||||
margin-right: .2em;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.emoji-item .emoji-name {
|
||||
font-weight: bold;
|
||||
}
|
94
src/Element/Textarea.tsx
Normal file
94
src/Element/Textarea.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
||||
import "./Textarea.css";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
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 { db } from "Db";
|
||||
import { MetadataCache } from "Db/User";
|
||||
|
||||
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, picture, 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>
|
||||
)
|
||||
}
|
||||
|
||||
const Textarea = ({ users, onChange, ...rest }: any) => {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const allUsers = useLiveQuery(
|
||||
() => db.users
|
||||
.where("npub").startsWithIgnoreCase(query)
|
||||
.or("name").startsWithIgnoreCase(query)
|
||||
.or("display_name").startsWithIgnoreCase(query)
|
||||
.or("nip05").startsWithIgnoreCase(query)
|
||||
.limit(5)
|
||||
.toArray(),
|
||||
[query],
|
||||
);
|
||||
|
||||
const userDataProvider = (token: string) => {
|
||||
setQuery(token)
|
||||
return allUsers
|
||||
}
|
||||
|
||||
const emojiDataProvider = (token: string) => {
|
||||
return emoji(token)
|
||||
.slice(0, 10)
|
||||
.map(({ name, char }) => ({ name, char }));
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactTextareaAutocomplete
|
||||
{...rest}
|
||||
loadingComponent={() => <span>Loading....</span>}
|
||||
placeholder="Say something!"
|
||||
onChange={onChange}
|
||||
textAreaComponent={TextareaAutosize}
|
||||
trigger={{
|
||||
":": {
|
||||
dataProvider: emojiDataProvider,
|
||||
component: EmojiItem,
|
||||
output: (item: EmojiItemProps, trigger) => item.char
|
||||
},
|
||||
"@": {
|
||||
afterWhitespace: true,
|
||||
dataProvider: userDataProvider,
|
||||
component: (props: any) => <UserItem {...props.entity} />,
|
||||
output: (item: any) => `@${hexToBech32("npub", item.pubkey)}`
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Textarea
|
103
src/Element/Thread.tsx
Normal file
103
src/Element/Thread.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { TaggedRawEvent, u256 } from "Nostr";
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { eventLink } from "Util";
|
||||
import Note from "Element/Note";
|
||||
import NoteGhost from "Element/NoteGhost";
|
||||
|
||||
export interface ThreadProps {
|
||||
this?: u256,
|
||||
notes?: TaggedRawEvent[]
|
||||
}
|
||||
export default function Thread(props: ThreadProps) {
|
||||
const thisEvent = props.this;
|
||||
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 chains = useMemo(() => {
|
||||
let chains = new Map<u256, NEvent[]>();
|
||||
parsedNotes?.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
|
||||
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
|
||||
if (replyTo) {
|
||||
if (!chains.has(replyTo)) {
|
||||
chains.set(replyTo, [v]);
|
||||
} else {
|
||||
chains.get(replyTo)!.push(v);
|
||||
}
|
||||
} else if (v.Tags.length > 0) {
|
||||
console.log("Not replying to anything: ", v);
|
||||
}
|
||||
});
|
||||
|
||||
return chains;
|
||||
}, [notes]);
|
||||
|
||||
const brokenChains = useMemo(() => {
|
||||
return Array.from(chains?.keys()).filter(a => !parsedNotes?.some(b => b.Id === a));
|
||||
}, [chains]);
|
||||
|
||||
const mentionsRoot = useMemo(() => {
|
||||
return parsedNotes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
|
||||
}, [chains]);
|
||||
|
||||
function renderRoot() {
|
||||
if (root) {
|
||||
return <Note
|
||||
data-ev={root}
|
||||
related={notes}
|
||||
isThread />
|
||||
} else {
|
||||
return <NoteGhost>
|
||||
Loading thread root.. ({notes?.length} notes loaded)
|
||||
</NoteGhost>
|
||||
}
|
||||
}
|
||||
|
||||
function renderChain(from: u256) {
|
||||
if (from && chains) {
|
||||
let replies = chains.get(from);
|
||||
if (replies) {
|
||||
return (
|
||||
<div className="indented">
|
||||
{replies.map(a => {
|
||||
return (
|
||||
<>
|
||||
<Note data-ev={a}
|
||||
key={a.Id}
|
||||
related={notes}
|
||||
highlight={thisEvent === a.Id} />
|
||||
{renderChain(a.Id)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderRoot()}
|
||||
{root ? renderChain(root.Id) : null}
|
||||
{root ? null : <>
|
||||
<h3>Other Replies</h3>
|
||||
{brokenChains.map(a => {
|
||||
return (
|
||||
<>
|
||||
<NoteGhost key={a}>
|
||||
Missing event <Link to={eventLink(a)}>{a.substring(0, 8)}</Link>
|
||||
</NoteGhost>
|
||||
{renderChain(a)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</>}
|
||||
</>
|
||||
);
|
||||
}
|
45
src/Element/Timeline.tsx
Normal file
45
src/Element/Timeline.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
|
||||
import { TaggedRawEvent } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import LoadMore from "Element/LoadMore";
|
||||
import Note from "Element/Note";
|
||||
import NoteReaction from "Element/NoteReaction";
|
||||
|
||||
export interface TimelineProps {
|
||||
postsOnly: boolean,
|
||||
subject: TimelineSubject,
|
||||
method: "TIME_RANGE" | "LIMIT_UNTIL"
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of notes by pubkeys
|
||||
*/
|
||||
export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) {
|
||||
const { main, others, loadMore } = useTimelineFeed(subject, {
|
||||
method
|
||||
});
|
||||
|
||||
const mainFeed = useMemo(() => {
|
||||
return main?.sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true);
|
||||
}, [main]);
|
||||
|
||||
function eventElement(e: TaggedRawEvent) {
|
||||
switch (e.kind) {
|
||||
case EventKind.TextNote: {
|
||||
return <Note key={e.id} data={e} related={others} />
|
||||
}
|
||||
case EventKind.Reaction:
|
||||
case EventKind.Repost: {
|
||||
return <NoteReaction data={e} key={e.id} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{mainFeed.map(eventElement)}
|
||||
{mainFeed.length > 0 ? <LoadMore onLoadMore={loadMore} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
19
src/Element/UnreadCount.css
Normal file
19
src/Element/UnreadCount.css
Normal file
@ -0,0 +1,19 @@
|
||||
.pill {
|
||||
font-size: var(--font-size-small);
|
||||
display: inline-block;
|
||||
background-color: var(--gray-tertiary);
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
color: var(--font-color);
|
||||
margin: 2px 5px;
|
||||
}
|
||||
|
||||
.pill.unread {
|
||||
background-color: var(--gray);
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
cursor: pointer;
|
||||
}
|
11
src/Element/UnreadCount.tsx
Normal file
11
src/Element/UnreadCount.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import "./UnreadCount.css"
|
||||
|
||||
const UnreadCount = ({ unread }: { unread: number }) => {
|
||||
return (
|
||||
<span className={`pill ${unread > 0 ? 'unread' : ''}`}>
|
||||
{unread}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnreadCount
|
6
src/Element/ZapButton.css
Normal file
6
src/Element/ZapButton.css
Normal file
@ -0,0 +1,6 @@
|
||||
.zap-button {
|
||||
color: var(--bg-color);
|
||||
background-color: var(--highlight);
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
}
|
26
src/Element/ZapButton.tsx
Normal file
26
src/Element/ZapButton.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import "./ZapButton.css";
|
||||
import { faBolt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useState } from "react";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import { HexKey } from "Nostr";
|
||||
import LNURLTip from "Element/LNURLTip";
|
||||
|
||||
const ZapButton = ({ pubkey }: { pubkey: HexKey }) => {
|
||||
const profile = useProfile(pubkey)?.get(pubkey);
|
||||
const [zap, setZap] = useState(false);
|
||||
const svc = profile?.lud16 || profile?.lud06;
|
||||
|
||||
if (!svc) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="zap-button" onClick={(e) => setZap(true)}>
|
||||
<FontAwesomeIcon icon={faBolt} />
|
||||
</div>
|
||||
<LNURLTip svc={svc} show={zap} onClose={() => setZap(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZapButton;
|
Reference in New Issue
Block a user