chore: rename Dirs

This commit is contained in:
2023-01-20 11:30:04 +00:00
parent ab1efc2e2e
commit 3533f26e4e
90 changed files with 0 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View File

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

53
src/Element/DM.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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} />)}
</>
)
}

View 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`} />
}

View 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}`} />
}

View File

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

View 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
View File

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

12
src/Element/Hashtag.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)} />
&nbsp;@&nbsp;
<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
View 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
View 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>
)
}

View 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
View 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
View 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
View 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>
);
}

View 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;
}

View 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
View 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}</>
}

View 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;
}

View 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>
)
}

View 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;
}

View 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;
}

View 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);
}

View 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
View 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
View File

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

89
src/Element/Relay.tsx Normal file
View 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`}
&nbsp;
<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
View 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
View 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
View 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
View 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
View 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
View 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}
</>
);
}

View 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;
}

View 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

View 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
View 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;