This commit is contained in:
Kieran 2023-01-16 17:48:25 +00:00
parent b6a877877f
commit 8aed2c5550
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
39 changed files with 706 additions and 486 deletions

View File

@ -2,7 +2,7 @@ import * as secp from "@noble/secp256k1";
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import { HexKey, u256 } from "./nostr"; import { HexKey, u256 } from "./nostr";
export async function openFile() { export async function openFile(): Promise<File | undefined> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let elm = document.createElement("input"); let elm = document.createElement("input");
elm.type = "file"; elm.type = "file";
@ -10,6 +10,8 @@ export async function openFile() {
let elm = e.target as HTMLInputElement; let elm = e.target as HTMLInputElement;
if (elm.files) { if (elm.files) {
resolve(elm.files[0]); resolve(elm.files[0]);
} else {
resolve(undefined);
} }
}; };
elm.click(); elm.click();

View File

@ -3,7 +3,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons"; import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
import { useCopy } from "../useCopy"; import { useCopy } from "../useCopy";
export default function Copy({ text, maxSize = 32 }) { export interface CopyProps {
text: string,
maxSize?: number
}
export default function Copy({ text, maxSize = 32 }: CopyProps) {
const { copy, copied, error } = useCopy(); const { copy, copied, error } = useCopy();
const sliceLength = maxSize / 2 const sliceLength = maxSize / 2
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}:${text.slice(-sliceLength)}` : text const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}:${text.slice(-sliceLength)}` : text

View File

@ -40,7 +40,7 @@ export default function DM(props: DMProps) {
<div className={`flex dm f-col${props.data.pubkey === pubKey ? " me" : ""}`} ref={ref}> <div className={`flex dm f-col${props.data.pubkey === pubKey ? " me" : ""}`} ref={ref}>
<div><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div> <div><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div>
<div className="w-max"> <div className="w-max">
<Text content={content} /> <Text content={content} tags={[]} users={new Map()} />
</div> </div>
</div> </div>
) )

View File

@ -2,28 +2,33 @@ import { useSelector } from "react-redux";
import useEventPublisher from "../feed/EventPublisher"; import useEventPublisher from "../feed/EventPublisher";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons"; import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons";
import { HexKey } from "../nostr";
import { RootState } from "../state/Store";
export default function FollowButton(props) { export interface FollowButtonProps {
pubkey: HexKey,
className?: string,
}
export default function FollowButton(props: FollowButtonProps) {
const pubkey = props.pubkey; const pubkey = props.pubkey;
const publiser = useEventPublisher(); const publiser = useEventPublisher();
const follows = useSelector(s => s.login.follows); const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
let isFollowing = follows?.includes(pubkey) ?? false; const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button`
const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button`
const className = props.className ? `${baseClassName} ${props.className}` : `${baseClassName}`; const className = props.className ? `${baseClassName} ${props.className}` : `${baseClassName}`;
async function follow(pubkey) { async function follow(pubkey: HexKey) {
let ev = await publiser.addFollow(pubkey); let ev = await publiser.addFollow(pubkey);
publiser.broadcast(ev); publiser.broadcast(ev);
} }
async function unfollow(pubkey) { async function unfollow(pubkey: HexKey) {
let ev = await publiser.removeFollow(pubkey); let ev = await publiser.removeFollow(pubkey);
publiser.broadcast(ev); publiser.broadcast(ev);
} }
return ( return (
<div className={className} onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}> <div className={className} onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}>
<FontAwesomeIcon icon={isFollowing ? faUserMinus : faUserPlus} size="lg" /> <FontAwesomeIcon icon={isFollowing ? faUserMinus : faUserPlus} size="lg" />
</div> </div>
) )
} }

View File

@ -1,7 +1,12 @@
import useEventPublisher from "../feed/EventPublisher"; import useEventPublisher from "../feed/EventPublisher";
import { HexKey } from "../nostr";
import ProfilePreview from "./ProfilePreview"; import ProfilePreview from "./ProfilePreview";
export default function FollowListBase({ pubkeys, title}) { export interface FollowListBaseProps {
pubkeys: HexKey[],
title?: string
}
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) {
const publisher = useEventPublisher(); const publisher = useEventPublisher();
async function followAll() { async function followAll() {

View File

@ -1,9 +1,14 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useFollowersFeed from "../feed/FollowersFeed"; import useFollowersFeed from "../feed/FollowersFeed";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import FollowListBase from "./FollowListBase"; import FollowListBase from "./FollowListBase";
export default function FollowersList({ pubkey }) { export interface FollowersListProps {
pubkey: HexKey
}
export default function FollowersList({ pubkey }: FollowersListProps) {
const feed = useFollowersFeed(pubkey); const feed = useFollowersFeed(pubkey);
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
@ -11,5 +16,5 @@ export default function FollowersList({ pubkey }) {
return [...new Set(contactLists?.map(a => a.pubkey))]; return [...new Set(contactLists?.map(a => a.pubkey))];
}, [feed]); }, [feed]);
return <FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`}/> return <FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
} }

View File

@ -1,9 +1,14 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useFollowsFeed from "../feed/FollowsFeed"; import useFollowsFeed from "../feed/FollowsFeed";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import FollowListBase from "./FollowListBase"; import FollowListBase from "./FollowListBase";
export default function FollowsList({ pubkey }) { export interface FollowsListProps {
pubkey: HexKey
}
export default function FollowsList({ pubkey }: FollowsListProps) {
const feed = useFollowsFeed(pubkey); const feed = useFollowsFeed(pubkey);
const pubkeys = useMemo(() => { const pubkeys = useMemo(() => {
@ -12,5 +17,5 @@ export default function FollowsList({ pubkey }) {
return [...new Set(pTags?.flat())]; return [...new Set(pTags?.flat())];
}, [feed]); }, [feed]);
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`}/> return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
} }

View File

@ -1,6 +1,6 @@
import './Hashtag.css' import './Hashtag.css'
const Hashtag = ({ children }) => { const Hashtag = ({ children }: any) => {
return ( return (
<span className="hashtag"> <span className="hashtag">
{children} {children}

View File

@ -1,11 +1,15 @@
import "./Invoice.css"; import "./Invoice.css";
import { useState } from "react"; import { useState } from "react";
// @ts-expect-error
import { decode as invoiceDecode } from "light-bolt11-decoder"; import { decode as invoiceDecode } from "light-bolt11-decoder";
import { useMemo } from "react"; import { useMemo } from "react";
import NoteTime from "./NoteTime"; import NoteTime from "./NoteTime";
import LNURLTip from "./LNURLTip"; import LNURLTip from "./LNURLTip";
export default function Invoice(props) { export interface InvoiceProps {
invoice: string
}
export default function Invoice(props: InvoiceProps) {
const invoice = props.invoice; const invoice = props.invoice;
const [showInvoice, setShowInvoice] = useState(false); const [showInvoice, setShowInvoice] = useState(false);
@ -13,10 +17,10 @@ export default function Invoice(props) {
try { try {
let parsed = invoiceDecode(invoice); let parsed = invoiceDecode(invoice);
let amount = parseInt(parsed.sections.find(a => a.name === "amount")?.value); let amount = parseInt(parsed.sections.find((a: any) => a.name === "amount")?.value);
let timestamp = parseInt(parsed.sections.find(a => a.name === "timestamp")?.value); let timestamp = parseInt(parsed.sections.find((a: any) => a.name === "timestamp")?.value);
let expire = parseInt(parsed.sections.find(a => a.name === "expiry")?.value); let expire = parseInt(parsed.sections.find((a: any) => a.name === "expiry")?.value);
let description = parsed.sections.find(a => a.name === "description")?.value; let description = parsed.sections.find((a: any) => a.name === "description")?.value;
let ret = { let ret = {
amount: !isNaN(amount) ? (amount / 1000) : 0, amount: !isNaN(amount) ? (amount / 1000) : 0,
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null, expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,

View File

@ -5,31 +5,67 @@ import Modal from "./Modal";
import QrCode from "./QrCode"; import QrCode from "./QrCode";
import Copy from "./Copy"; import Copy from "./Copy";
export default function LNURLTip(props) { 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 onClose = props.onClose || (() => { });
const service = props.svc; const service = props.svc;
const show = props.show || false; const show = props.show || false;
const amounts = [50, 100, 500, 1_000, 5_000, 10_000]; const amounts = [50, 100, 500, 1_000, 5_000, 10_000, 50_000];
const [payService, setPayService] = useState(""); const [payService, setPayService] = useState<LNURLService>();
const [amount, setAmount] = useState(0); const [amount, setAmount] = useState<number>();
const [customAmount, setCustomAmount] = useState(0); const [customAmount, setCustomAmount] = useState<number>();
const [invoice, setInvoice] = useState(null); const [invoice, setInvoice] = useState<LNURLInvoice>();
const [comment, setComment] = useState(""); const [comment, setComment] = useState<string>();
const [error, setError] = useState(""); const [error, setError] = useState<string>();
const [success, setSuccess] = useState(null); const [success, setSuccess] = useState<LNURLSuccessAction>();
useEffect(() => { useEffect(() => {
if (show && !props.invoice) { if (show && !props.invoice) {
loadService() loadService()
.then(a => setPayService(a)) .then(a => setPayService(a!))
.catch(() => setError("Failed to load LNURL service")); .catch(() => setError("Failed to load LNURL service"));
} else { } else {
setPayService(""); setPayService(undefined);
setError(""); setError(undefined);
setInvoice(props.invoice ? { pr: props.invoice } : null); setInvoice(props.invoice ? { pr: props.invoice } : undefined);
setAmount(0); setAmount(undefined);
setComment(""); setComment(undefined);
setSuccess(null); setSuccess(undefined);
} }
}, [show, service]); }, [show, service]);
@ -44,7 +80,7 @@ export default function LNURLTip(props) {
const metadata = useMemo(() => { const metadata = useMemo(() => {
if (payService) { if (payService) {
let meta = JSON.parse(payService.metadata); let meta: string[][] = JSON.parse(payService.metadata);
let desc = meta.find(a => a[0] === "text/plain"); let desc = meta.find(a => a[0] === "text/plain");
let image = meta.find(a => a[0] === "image/png;base64"); let image = meta.find(a => a[0] === "image/png;base64");
return { return {
@ -55,37 +91,40 @@ export default function LNURLTip(props) {
return null; return null;
}, [payService]); }, [payService]);
const selectAmount = (a) => { const selectAmount = (a: number) => {
setError(""); setError(undefined);
setInvoice(null); setInvoice(undefined);
setAmount(a); setAmount(a);
}; };
async function fetchJson(url) { async function fetchJson<T>(url: string) {
let rsp = await fetch(url); let rsp = await fetch(url);
if (rsp.ok) { if (rsp.ok) {
let data = await rsp.json(); let data: T = await rsp.json();
console.log(data); console.log(data);
setError(""); setError(undefined);
return data; return data;
} }
return null; return null;
} }
async function loadService() { async function loadService(): Promise<LNURLService | null> {
let isServiceUrl = service.toLowerCase().startsWith("lnurl"); if (service) {
if (isServiceUrl) { let isServiceUrl = service.toLowerCase().startsWith("lnurl");
let serviceUrl = bech32ToText(service); if (isServiceUrl) {
return await fetchJson(serviceUrl); let serviceUrl = bech32ToText(service);
} else { return await fetchJson(serviceUrl);
let ns = service.split("@"); } else {
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`); let ns = service.split("@");
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
}
} }
return null;
} }
async function loadInvoice() { async function loadInvoice() {
if (amount === 0) return null; if (!amount || !payService) return null;
const url = `${payService.callback}?amount=${parseInt(amount * 1000)}&comment=${encodeURIComponent(comment)}`; const url = `${payService.callback}?amount=${Math.floor(amount * 1000)}${comment ? `&comment=${encodeURIComponent(comment)}` : ""}`;
try { try {
let rsp = await fetch(url); let rsp = await fetch(url);
if (rsp.ok) { if (rsp.ok) {
@ -110,21 +149,21 @@ export default function LNURLTip(props) {
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000; let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
return ( return (
<div className="flex mb10"> <div className="flex mb10">
<input type="number" min={min} max={max} className="f-grow mr10" value={customAmount} onChange={(e) => setCustomAmount(e.target.value)} /> <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 className="btn" onClick={() => selectAmount(customAmount!)}>Confirm</div>
</div> </div>
); );
} }
async function payWebLN() { async function payWebLN() {
try { try {
if (!window.webln.enabled) { if (!window.webln!.enabled) {
await window.webln.enable(); await window.webln!.enable();
} }
let res = await window.webln.sendPayment(invoice.pr); let res = await window.webln!.sendPayment(invoice!.pr);
console.log(res); console.log(res);
setSuccess(invoice.successAction || {}); setSuccess(invoice!.successAction || {});
} catch (e) { } catch (e: any) {
setError(e.toString()); setError(e.toString());
console.warn(e); console.warn(e);
} }
@ -145,7 +184,7 @@ export default function LNURLTip(props) {
<> <>
<div className="f-ellipsis mb10">{metadata?.description ?? service}</div> <div className="f-ellipsis mb10">{metadata?.description ?? service}</div>
<div className="flex"> <div className="flex">
{payService?.commentAllowed > 0 ? {(payService?.commentAllowed ?? 0) > 0 ?
<input type="text" placeholder="Comment" className="mb10 f-grow" maxLength={payService?.commentAllowed} onChange={(e) => setComment(e.target.value)} /> : null} <input type="text" placeholder="Comment" className="mb10 f-grow" maxLength={payService?.commentAllowed} onChange={(e) => setComment(e.target.value)} /> : null}
</div> </div>
<div className="mb10"> <div className="mb10">
@ -158,7 +197,7 @@ export default function LNURLTip(props) {
</span> : null} </span> : null}
</div> </div>
{amount === -1 ? custom() : null} {amount === -1 ? custom() : null}
{amount > 0 ? <div className="btn mb10" onClick={() => loadInvoice()}>Get Invoice</div> : null} {(amount ?? 0) > 0 ? <div className="btn mb10" onClick={() => loadInvoice()}>Get Invoice</div> : null}
</> </>
) )
} }

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
export default function LazyImage(props) { export default function LazyImage(props: any) {
const { ref, inView, entry } = useInView(); const { ref, inView, entry } = useInView();
const [shown, setShown] = useState(false); const [shown, setShown] = useState(false);

View File

@ -1,7 +1,13 @@
import "./Modal.css"; import "./Modal.css";
import { useEffect } from "react" import { useEffect } from "react"
import * as React from "react";
export default function Modal(props) { export interface ModalProps {
onClose?: () => void,
children: React.ReactNode
}
export default function Modal(props: ModalProps) {
const onClose = props.onClose || (() => { }); const onClose = props.onClose || (() => { });
useEffect(() => { useEffect(() => {
@ -10,7 +16,7 @@ export default function Modal(props) {
}, []); }, []);
return ( return (
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(e); }}> <div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}>
{props.children} {props.children}
</div> </div>
) )

View File

@ -1,83 +0,0 @@
import { useQuery } from "react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import './Nip05.css'
function fetchNip05Pubkey(name, domain) {
if (!name || !domain) {
return Promise.resolve(null)
}
return fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`)
.then((res) => res.json())
.then(({ names }) => {
const match = Object.keys(names).find(n => {
return n.toLowerCase() === name.toLowerCase()
})
return names[match]
})
}
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000
export function useIsVerified(nip05, pubkey) {
const [name, domain] = nip05 ? nip05.split('@') : []
const address = domain && `${name}@${domain.toLowerCase()}`
const { isLoading, isError, isSuccess, isIdle, 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 }
}
const Nip05 = ({ nip05, pubkey }) => {
const [name, domain] = nip05 ? nip05.split('@') : []
const isDefaultUser = name === '_'
const { isVerified, couldNotVerify } = useIsVerified(nip05, pubkey)
return (
<div className="flex nip05" onClick={(ev) => ev.stopPropagation()}>
<div className="nick">
{!isDefaultUser && 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"
/>
)}
{isVerified && (
<FontAwesomeIcon
color={"var(--success)"}
icon={faCheck}
size="xs"
/>
)}
{couldNotVerify && (
<FontAwesomeIcon
color={"var(--error)"}
icon={faTriangleExclamation}
size="xs"
/>
)}
</span>
</div>
)
}
export default Nip05

90
src/element/Nip05.tsx Normal file
View File

@ -0,0 +1,90 @@
import { useQuery } from "react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, 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;
}
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;
}
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" onClick={(ev) => ev.stopPropagation()}>
<div className="nick">
{!isDefaultUser && 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"
/>
)}
{isVerified && (
<FontAwesomeIcon
color={"var(--success)"}
icon={faCheck}
size="xs"
/>
)}
{couldNotVerify && (
<FontAwesomeIcon
color={"var(--error)"}
icon={faTriangleExclamation}
size="xs"
/>
)}
</span>
</div>
)
}
export default Nip05

View File

@ -2,7 +2,7 @@ import "./Note.css";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Event from "../nostr/Event"; import { default as NEvent } from "../nostr/Event";
import ProfileImage from "./ProfileImage"; import ProfileImage from "./ProfileImage";
import Text from "./Text"; import Text from "./Text";
import { eventLink, hexToBech32 } from "../Util"; import { eventLink, hexToBech32 } from "../Util";
@ -10,11 +10,26 @@ import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime"; import NoteTime from "./NoteTime";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import useProfile from "../feed/ProfileFeed"; import useProfile from "../feed/ProfileFeed";
import { TaggedRawEvent, u256 } from "../nostr";
export default function Note(props) { export interface NoteProps {
data?: TaggedRawEvent,
isThread?: boolean,
reactions: TaggedRawEvent[],
deletion: TaggedRawEvent[],
highlight?: boolean,
options?: {
showHeader?: boolean,
showTime?: boolean,
showFooter?: boolean
},
["data-ev"]?: NEvent
}
export default function Note(props: NoteProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isThread, reactions, deletion, hightlight, options: opt, ["data-ev"]: parsedEvent } = props const { data, isThread, reactions, deletion, highlight, options: opt, ["data-ev"]: parsedEvent } = props
const ev = useMemo(() => parsedEvent ?? new Event(data), [data]); const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useProfile(pubKeys); const users = useProfile(pubKeys);
@ -31,10 +46,10 @@ export default function Note(props) {
if (deletion?.length > 0) { if (deletion?.length > 0) {
return (<b className="error">Deleted</b>); return (<b className="error">Deleted</b>);
} }
return <Text content={body} tags={ev.Tags} users={users || []} />; return <Text content={body} tags={ev.Tags} users={users || new Map()} />;
}, [props]); }, [props]);
function goToEvent(e, id) { function goToEvent(e: any, id: u256) {
if (!window.location.pathname.startsWith("/e/")) { if (!window.location.pathname.startsWith("/e/")) {
e.stopPropagation(); e.stopPropagation();
navigate(eventLink(id)); navigate(eventLink(id));
@ -48,13 +63,21 @@ export default function Note(props) {
const maxMentions = 2; const maxMentions = 2;
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
let mentions = ev.Thread?.PubKeys?.map(a => [a, users ? users[a] : null])?.map(a => (a[1]?.name?.length ?? 0) > 0 ? a[1].name : hexToBech32("npub", a[0]).substring(0, 12)) let mentions: string[] = [];
.sort((a, b) => a.startsWith("npub") ? 1 : -1); 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 othersLength = mentions.length - maxMentions
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", "); let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
return ( return (
<div className="reply"> <div className="reply">
{(pubMentions?.length ?? 0) > 0 ? pubMentions : hexToBech32("note", replyId)?.substring(0, 12)} {(pubMentions?.length ?? 0) > 0 ? pubMentions : replyId ? hexToBech32("note", replyId)?.substring(0, 12) : ""}
</div> </div>
) )
} }
@ -71,10 +94,10 @@ export default function Note(props) {
} }
return ( return (
<div className={`note ${hightlight ? "active" : ""} ${isThread ? "thread" : ""}`}> <div className={`note ${highlight ? "active" : ""} ${isThread ? "thread" : ""}`}>
{options.showHeader ? {options.showHeader ?
<div className="header flex"> <div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag()} /> <ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
{options.showTime ? {options.showTime ?
<div className="info"> <div className="info">
<NoteTime from={ev.CreatedAt * 1000} /> <NoteTime from={ev.CreatedAt * 1000} />

View File

@ -1,91 +0,0 @@
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";
export function NoteCreator(props) {
const replyTo = props.replyTo;
const onSend = props.onSend;
const show = props.show || false;
const autoFocus = props.autoFocus || false;
const publisher = useEventPublisher();
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [active, setActive] = useState(false);
async function sendNote() {
let ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
console.debug("Sending note: ", ev);
publisher.broadcast(ev);
setNote("");
if (typeof onSend === "function") {
onSend();
}
setActive(false);
}
async function attachFile() {
try {
let file = await openFile();
let rsp = await VoidUpload(file);
let ext = file.name.match(FileExtensionRegex)[1];
// extension tricks note parser to embed the content
let url = rsp.metadata.url ?? `https://void.cat/d/${rsp.id}.${ext}`;
setNote(n => `${n}\n${url}`);
} catch (error) {
setError(error?.message)
}
}
function onChange(ev) {
const { value } = ev.target
setNote(value)
if (value) {
setActive(true)
} else {
setActive(false)
}
}
function onSubmit(ev) {
ev.stopPropagation();
sendNote()
}
if (!show) return false;
return (
<>
<div className={`flex note-creator ${replyTo ? 'note-reply' : ''}`}>
<div className="flex f-col mr10 f-grow">
<Textarea
autoFocus={autoFocus}
className={`textarea ${active ? "textarea--focused" : ""}`}
onChange={onChange}
value={note}
onFocus={() => setActive(true)}
/>
{active && note && (
<div className="actions flex f-row">
<div className="attachment flex f-row">
{error.length > 0 ? <b className="error">{error}</b> : null}
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div>
<button type="button" className="btn" onClick={onSubmit}>
{replyTo ? 'Reply' : 'Send'}
</button>
</div>
)}
</div>
</div>
</>
);
}

101
src/element/NoteCreator.tsx Normal file
View File

@ -0,0 +1,101 @@
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("");
const [error, setError] = useState("");
const [active, setActive] = useState(false);
async function sendNote() {
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 ? <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>
</>
);
}

View File

@ -8,21 +8,29 @@ import { normalizeReaction, Reaction } from "../Util";
import { NoteCreator } from "./NoteCreator"; import { NoteCreator } from "./NoteCreator";
import LNURLTip from "./LNURLTip"; import LNURLTip from "./LNURLTip";
import useProfile from "../feed/ProfileFeed"; import useProfile from "../feed/ProfileFeed";
import { default as NEvent } from "../nostr/Event";
import { RootState } from "../state/Store";
import { TaggedRawEvent } from "../nostr";
export default function NoteFooter(props) { export interface NoteFooterProps {
reactions: TaggedRawEvent[],
ev: NEvent
}
export default function NoteFooter(props: NoteFooterProps) {
const reactions = props.reactions; const reactions = props.reactions;
const ev = props.ev; const ev = props.ev;
const login = useSelector(s => s.login.publicKey); const login = useSelector<RootState, string | undefined>(s => s.login.publicKey);
const author = useProfile(ev.RootPubKey); const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [reply, setReply] = useState(false); const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login; const isMine = ev.RootPubKey === login;
const groupReactions = useMemo(() => { const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { Content }) => { return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(Content ?? ""); let r = normalizeReaction(content ?? "");
const amount = acc[r] || 0 const amount = acc[r] || 0
return { ...acc, [r]: amount + 1 } return { ...acc, [r]: amount + 1 }
}, { }, {
@ -31,11 +39,11 @@ export default function NoteFooter(props) {
}); });
}, [reactions]); }, [reactions]);
function hasReacted(emoji) { function hasReacted(emoji: string) {
return reactions?.find(({ PubKey, Content }) => Content === emoji && PubKey === login) return reactions?.some(({ pubkey, content }) => content === emoji && pubkey === login)
} }
async function react(content) { async function react(content: string) {
let evLike = await publisher.react(ev, content); let evLike = await publisher.react(ev, content);
publisher.broadcast(evLike); publisher.broadcast(evLike);
} }
@ -66,7 +74,7 @@ export default function NoteFooter(props) {
return null; return null;
} }
function reactionIcon(content, reacted) { function reactionIcon(content: string, reacted: boolean) {
switch (content) { switch (content) {
case Reaction.Positive: { case Reaction.Positive: {
return <FontAwesomeIcon color={reacted ? "red" : "currentColor"} icon={faHeart} />; return <FontAwesomeIcon color={reacted ? "red" : "currentColor"} icon={faHeart} />;
@ -108,10 +116,10 @@ export default function NoteFooter(props) {
<NoteCreator <NoteCreator
autoFocus={true} autoFocus={true}
replyTo={ev} replyTo={ev}
onSend={(e) => setReply(false)} onSend={() => setReply(false)}
show={reply} show={reply}
/> />
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={(e) => setTip(false)} show={tip} /> <LNURLTip svc={author?.lud16 || author?.lud06 || ""} onClose={() => setTip(false)} show={tip} />
</> </>
) )
} }

View File

@ -1,7 +1,7 @@
import "./Note.css"; import "./Note.css";
import ProfileImage from "./ProfileImage"; import ProfileImage from "./ProfileImage";
export default function NoteGhost(props) { export default function NoteGhost(props: any) {
return ( return (
<div className="note"> <div className="note">
<div className="header"> <div className="header">

View File

@ -2,14 +2,20 @@ import "./NoteReaction.css";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import Note from "./Note"; import Note from "./Note";
import ProfileImage from "./ProfileImage"; import ProfileImage from "./ProfileImage";
import Event from "../nostr/Event"; import { default as NEvent } from "../nostr/Event";
import { eventLink, hexToBech32 } from "../Util"; import { eventLink, hexToBech32 } from "../Util";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useMemo } from "react"; import { useMemo } from "react";
import NoteTime from "./NoteTime"; import NoteTime from "./NoteTime";
import { RawEvent, TaggedRawEvent } from "../nostr";
export default function NoteReaction(props) { export interface NoteReactionProps {
const ev = props["data-ev"] || new Event(props.data); data?: TaggedRawEvent,
["data-ev"]?: NEvent,
root?: TaggedRawEvent
}
export default function NoteReaction(props: NoteReactionProps) {
const ev = props["data-ev"] || new NEvent(props.data);
const refEvent = useMemo(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {
@ -25,7 +31,7 @@ export default function NoteReaction(props) {
return null; return null;
} }
function mapReaction(c) { function mapReaction(c: string) {
switch (c) { switch (c) {
case "+": return "❤️"; case "+": return "❤️";
case "-": return "👎"; case "-": return "👎";
@ -51,8 +57,8 @@ export default function NoteReaction(props) {
function extractRoot() { function extractRoot() {
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0) { if (ev?.Kind === EventKind.Repost && ev.Content.length > 0) {
try { try {
let r = JSON.parse(ev.Content); let r: RawEvent = JSON.parse(ev.Content);
return r; return r as TaggedRawEvent;
} catch (e) { } catch (e) {
console.error("Could not load reposted content", e); console.error("Could not load reposted content", e);
} }
@ -74,7 +80,7 @@ export default function NoteReaction(props) {
</div> </div>
</div> </div>
{root ? <Note data={root} options={opt} /> : null} {root ? <Note data={root} options={opt} reactions={[]} deletion={[]} /> : null}
{!root && refEvent ? <p><Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link></p> : null} {!root && refEvent ? <p><Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link></p> : null}
</div> </div>
); );

View File

@ -4,8 +4,14 @@ const MinuteInMs = 1_000 * 60;
const HourInMs = MinuteInMs * 60; const HourInMs = MinuteInMs * 60;
const DayInMs = HourInMs * 24; const DayInMs = HourInMs * 24;
export default function NoteTime({ from, fallback = '' }) { export interface NoteTimeProps {
const [time, setTime] = useState(""); from: number,
fallback?: string
}
export default function NoteTime(props: NoteTimeProps) {
const [time, setTime] = useState<string>();
const { from, fallback } = props;
function calcTime() { function calcTime() {
let fromDate = new Date(from); let fromDate = new Date(from);
@ -16,9 +22,9 @@ export default function NoteTime({ from, fallback = '' }) {
} else if (absAgo > HourInMs) { } else if (absAgo > HourInMs) {
return `${fromDate.getHours().toString().padStart(2, '0')}:${fromDate.getMinutes().toString().padStart(2, '0')}`; return `${fromDate.getHours().toString().padStart(2, '0')}:${fromDate.getMinutes().toString().padStart(2, '0')}`;
} else if (absAgo < MinuteInMs) { } else if (absAgo < MinuteInMs) {
return fallback return fallback
} else { } else {
let mins = parseInt(absAgo / MinuteInMs); let mins = Math.floor(absAgo / MinuteInMs);
let minutes = mins === 1 ? 'min' : 'mins' let minutes = mins === 1 ? 'min' : 'mins'
return `${mins} ${minutes} ago`; return `${mins} ${minutes} ago`;
} }

View File

@ -1,4 +1,5 @@
import "./ProfileImage.css"; import "./ProfileImage.css";
// @ts-ignore
import Nostrich from "../nostrich.jpg"; import Nostrich from "../nostrich.jpg";
import { useMemo } from "react"; import { useMemo } from "react";
@ -7,31 +8,40 @@ import useProfile from "../feed/ProfileFeed";
import { hexToBech32, profileLink } from "../Util"; import { hexToBech32, profileLink } from "../Util";
import LazyImage from "./LazyImage"; import LazyImage from "./LazyImage";
import Nip05 from "./Nip05"; import Nip05 from "./Nip05";
import { HexKey } from "../nostr";
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }) { 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 navigate = useNavigate();
const user = useProfile(pubkey); const user = useProfile(pubkey)?.get(pubkey);
const hasImage = (user?.picture?.length ?? 0) > 0; const hasImage = (user?.picture?.length ?? 0) > 0;
const name = useMemo(() => { const name = useMemo(() => {
let name = hexToBech32("npub", pubkey).substring(0, 12); let name = hexToBech32("npub", pubkey).substring(0, 12);
if (user?.display_name?.length > 0) { if ((user?.display_name?.length ?? 0) > 0) {
name = user.display_name; name = user!.display_name!;
} else if (user?.name?.length > 0) { } else if ((user?.name?.length ?? 0) > 0) {
name = user.name; name = user!.name!;
} }
return name; return name;
}, [user]); }, [user]);
return ( return (
<div className={`pfp ${className}`}> <div className={`pfp${className ? ` ${className}` : ""}`}>
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(link ?? profileLink(pubkey))} /> <LazyImage src={hasImage ? user!.picture : Nostrich} onClick={() => navigate(link ?? profileLink(pubkey))} />
{showUsername && (<div className="f-grow"> {showUsername && (<div className="f-grow">
<Link key={pubkey} to={link ?? profileLink(pubkey)}> <Link key={pubkey} to={link ?? profileLink(pubkey)}>
<div className="profile-name"> <div className="profile-name">
<div>{name}</div> <div>{name}</div>
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />} {user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div> </div>
</Link> </Link>
{subHeader ? <>{subHeader}</> : null} {subHeader ? <>{subHeader}</> : null}
</div> </div>

View File

@ -2,10 +2,17 @@ import "./ProfilePreview.css";
import ProfileImage from "./ProfileImage"; import ProfileImage from "./ProfileImage";
import FollowButton from "./FollowButton"; import FollowButton from "./FollowButton";
import useProfile from "../feed/ProfileFeed"; import useProfile from "../feed/ProfileFeed";
import { HexKey } from "../nostr";
export default function ProfilePreview(props) { export interface ProfilePreviewProps {
pubkey: HexKey,
options?: {
about?: boolean
}
}
export default function ProfilePreview(props: ProfilePreviewProps) {
const pubkey = props.pubkey; const pubkey = props.pubkey;
const user = useProfile(pubkey); const user = useProfile(pubkey)?.get(pubkey);
const options = { const options = {
about: true, about: true,
...props.options ...props.options
@ -16,7 +23,7 @@ export default function ProfilePreview(props) {
<ProfileImage pubkey={pubkey} subHeader= <ProfileImage pubkey={pubkey} subHeader=
{options.about ? <div className="f-ellipsis about"> {options.about ? <div className="f-ellipsis about">
{user?.about} {user?.about}
</div> : null} /> </div> : undefined} />
<FollowButton pubkey={pubkey} className="ml5" /> <FollowButton pubkey={pubkey} className="ml5" />
</div> </div>
) )

View File

@ -1,11 +1,19 @@
import QRCodeStyling from "qr-code-styling"; import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export default function QrCode(props) { export interface QrCodeProps {
const qrRef = useRef(); data?: string,
link?: string,
avatar?: string,
height?: number,
width?: number
}
export default function QrCode(props: QrCodeProps) {
const qrRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (props.data?.length > 0) { if ((props.data?.length ?? 0) > 0 && qrRef.current) {
let qr = new QRCodeStyling({ let qr = new QRCodeStyling({
width: props.width || 256, width: props.width || 256,
height: props.height || 256, height: props.height || 256,
@ -28,11 +36,11 @@ export default function QrCode(props) {
if (props.link) { if (props.link) {
qrRef.current.onclick = function (e) { qrRef.current.onclick = function (e) {
let elm = document.createElement("a"); let elm = document.createElement("a");
elm.href = props.link; elm.href = props.link!;
elm.click(); elm.click();
} }
} }
} else { } else if (qrRef.current) {
qrRef.current.innerHTML = ""; qrRef.current.innerHTML = "";
} }
}, [props.data, props.link]); }, [props.data, props.link]);

View File

@ -6,23 +6,33 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { removeRelay, setRelays } from "../state/Login"; 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) { export default function Relay(props: RelayProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const relaySettings = useSelector(s => s.login.relays[props.addr]); const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = allRelaySettings[props.addr];
const state = useRelayState(props.addr); const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]); const name = useMemo(() => new URL(props.addr).host, [props.addr]);
const [showExtra, setShowExtra] = useState(false); const [showExtra, setShowExtra] = useState(false);
function configure(o) { function configure(o: RelaySettings) {
dispatch(setRelays({ dispatch(setRelays({
[props.addr]: o relays: {
...allRelaySettings,
[props.addr]: o
},
createdAt: Math.floor(new Date().getTime() / 1000)
})); }));
} }
let latency = parseInt(state?.avgLatency ?? 0); let latency = Math.floor(state?.avgLatency ?? 0);
return ( return (
<> <>
<div className={`relay w-max`}> <div className={`relay w-max`}>

View File

@ -10,8 +10,10 @@ import Hashtag from "./Hashtag";
import './Text.css' import './Text.css'
import { useMemo } from "react"; import { useMemo } from "react";
import Tag from "../nostr/Tag";
import { MetadataCache } from "../db/User";
function transformHttpLink(a) { function transformHttpLink(a: string) {
try { try {
const url = new URL(a); const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1; const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
@ -32,10 +34,10 @@ function transformHttpLink(a) {
case "mkv": case "mkv":
case "avi": case "avi":
case "m4v": { case "m4v": {
return <video key={url} src={url} controls /> return <video key={url.toString()} src={url.toString()} controls />
} }
default: default:
return <a key={url} href={url} onClick={(e) => e.stopPropagation()}>{url.toString()}</a> return <a key={url.toString()} href={url.toString()} onClick={(e) => e.stopPropagation()}>{url.toString()}</a>
} }
} else if (tweetId) { } else if (tweetId) {
return ( return (
@ -54,7 +56,7 @@ function transformHttpLink(a) {
key={youtubeId} key={youtubeId}
frameBorder="0" frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen="" allowFullScreen={true}
/> />
<br /> <br />
</> </>
@ -67,7 +69,7 @@ function transformHttpLink(a) {
return <a href={a} onClick={(e) => e.stopPropagation()}>{a}</a> return <a href={a} onClick={(e) => e.stopPropagation()}>{a}</a>
} }
function extractLinks(fragments) { function extractLinks(fragments: Fragment[]) {
return fragments.map(f => { return fragments.map(f => {
if (typeof f === "string") { if (typeof f === "string") {
return f.split(UrlRegex).map(a => { return f.split(UrlRegex).map(a => {
@ -81,7 +83,7 @@ function extractLinks(fragments) {
}).flat(); }).flat();
} }
function extractMentions(fragments, tags, users) { function extractMentions(fragments: Fragment[], tags: Tag[], users: Map<string, MetadataCache>) {
return fragments.map(f => { return fragments.map(f => {
if (typeof f === "string") { if (typeof f === "string") {
return f.split(MentionRegex).map((match) => { return f.split(MentionRegex).map((match) => {
@ -92,12 +94,12 @@ function extractMentions(fragments, tags, users) {
if (ref) { if (ref) {
switch (ref.Key) { switch (ref.Key) {
case "p": { case "p": {
let pUser = users[ref.PubKey]?.name ?? hexToBech32("npub", ref.PubKey).substring(0, 12); let pUser = users.get(ref.PubKey!)?.name ?? hexToBech32("npub", ref.PubKey!).substring(0, 12);
return <Link key={ref.PubKey} to={profileLink(ref.PubKey)} onClick={(e) => e.stopPropagation()}>@{pUser}</Link>; return <Link key={ref.PubKey} to={profileLink(ref.PubKey!)} onClick={(e) => e.stopPropagation()}>@{pUser}</Link>;
} }
case "e": { case "e": {
let eText = hexToBech32("note", ref.Event).substring(0, 12); let eText = hexToBech32("note", ref.Event!).substring(0, 12);
return <Link key={ref.Event} to={eventLink(ref.Event)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>; return <Link key={ref.Event} to={eventLink(ref.Event!)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>;
} }
} }
} }
@ -111,7 +113,7 @@ function extractMentions(fragments, tags, users) {
}).flat(); }).flat();
} }
function extractInvoices(fragments) { function extractInvoices(fragments: Fragment[]) {
return fragments.map(f => { return fragments.map(f => {
if (typeof f === "string") { if (typeof f === "string") {
return f.split(InvoiceRegex).map(i => { return f.split(InvoiceRegex).map(i => {
@ -126,7 +128,7 @@ function extractInvoices(fragments) {
}).flat(); }).flat();
} }
function extractHashtags(fragments) { function extractHashtags(fragments: Fragment[]) {
return fragments.map(f => { return fragments.map(f => {
if (typeof f === "string") { if (typeof f === "string") {
return f.split(HashtagRegex).map(i => { return f.split(HashtagRegex).map(i => {
@ -141,12 +143,12 @@ function extractHashtags(fragments) {
}).flat(); }).flat();
} }
function transformLi({ body, tags, users }) { function transformLi({ body, tags, users }: TextFragment) {
let fragments = transformText({ body, tags, users }) let fragments = transformText({ body, tags, users })
return <li>{fragments}</li> return <li>{fragments}</li>
} }
function transformParagraph({ body, tags, users }) { function transformParagraph({ body, tags, users }: TextFragment) {
const fragments = transformText({ body, tags, users }) const fragments = transformText({ body, tags, users })
if (fragments.every(f => typeof f === 'string')) { if (fragments.every(f => typeof f === 'string')) {
return <p>{fragments}</p> return <p>{fragments}</p>
@ -154,7 +156,7 @@ function transformParagraph({ body, tags, users }) {
return <>{fragments}</> return <>{fragments}</>
} }
function transformText({ body, tags, users }) { function transformText({ body, tags, users }: TextFragment) {
let fragments = extractMentions(body, tags, users); let fragments = extractMentions(body, tags, users);
fragments = extractLinks(fragments); fragments = extractLinks(fragments);
fragments = extractInvoices(fragments); fragments = extractInvoices(fragments);
@ -162,12 +164,26 @@ function transformText({ body, tags, users }) {
return fragments; return fragments;
} }
export default function Text({ content, tags, users }) { 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(() => { const components = useMemo(() => {
return { return {
p: (props) => transformParagraph({ body: props.children, tags, users }), p: (x: any) => transformParagraph({ body: x.children, tags, users }),
a: (props) => transformHttpLink(props.href), a: (x: any) => transformHttpLink(x.href),
li: (props) => transformLi({ body: props.children, tags, users }), li: (x: any) => transformLi({ body: x.children, tags, users }),
}; };
}, [content]); }, [content]);
return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown> return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown>

View File

@ -1,14 +1,13 @@
import { useLiveQuery } from "dexie-react-hooks";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import TextareaAutosize from "react-textarea-autosize";
// @ts-expect-error
import Nip05 from "./Nip05";
import "@webscopeio/react-textarea-autocomplete/style.css";
import "./Textarea.css"; import "./Textarea.css";
// @ts-expect-error // @ts-expect-error
import Nostrich from "../nostrich.jpg"; import Nostrich from "../nostrich.jpg";
import { useLiveQuery } from "dexie-react-hooks";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import TextareaAutosize from "react-textarea-autosize";
import "@webscopeio/react-textarea-autocomplete/style.css";
import Nip05 from "./Nip05";
import { hexToBech32 } from "../Util"; import { hexToBech32 } from "../Util";
import { db } from "../db"; import { db } from "../db";
import { MetadataCache } from "../db/User"; import { MetadataCache } from "../db/User";

View File

@ -1,16 +1,22 @@
import { useMemo } from "react"; import { useMemo } from "react";
import useTimelineFeed from "../feed/TimelineFeed"; import useTimelineFeed from "../feed/TimelineFeed";
import { HexKey, TaggedRawEvent, u256 } from "../nostr";
import EventKind from "../nostr/EventKind"; import EventKind from "../nostr/EventKind";
import Note from "./Note"; import Note from "./Note";
import NoteReaction from "./NoteReaction"; import NoteReaction from "./NoteReaction";
export interface TimelineProps {
global: boolean,
pubkeys: HexKey[]
}
/** /**
* A list of notes by pubkeys * A list of notes by pubkeys
*/ */
export default function Timeline({ global, pubkeys }) { export default function Timeline({ global, pubkeys }: TimelineProps) {
const feed = useTimelineFeed(pubkeys, global); const feed = useTimelineFeed(pubkeys, global);
function reaction(id, kind = EventKind.Reaction) { function reaction(id: u256, kind = EventKind.Reaction) {
return feed?.others?.filter(a => a.kind === kind && a.tags.some(b => b[0] === "e" && b[1] === id)); return feed?.others?.filter(a => a.kind === kind && a.tags.some(b => b[0] === "e" && b[1] === id));
} }
@ -18,7 +24,7 @@ export default function Timeline({ global, pubkeys }) {
return feed.main?.sort((a, b) => b.created_at - a.created_at); return feed.main?.sort((a, b) => b.created_at - a.created_at);
}, [feed]); }, [feed]);
function eventElement(e) { function eventElement(e: TaggedRawEvent) {
switch (e.kind) { switch (e.kind) {
case EventKind.TextNote: { case EventKind.TextNote: {
return <Note key={e.id} data={e} reactions={reaction(e.id)} deletion={reaction(e.id, EventKind.Deletion)} /> return <Note key={e.id} data={e} reactions={reaction(e.id)} deletion={reaction(e.id, EventKind.Deletion)} />
@ -30,5 +36,5 @@ export default function Timeline({ global, pubkeys }) {
} }
} }
return mainFeed.map(eventElement); return <>{mainFeed.map(eventElement)}</>;
} }

View File

@ -139,7 +139,7 @@ export default function useEventPublisher() {
return await signEvent(ev); return await signEvent(ev);
} }
}, },
addFollow: async (pkAdd: HexKey) => { addFollow: async (pkAdd: HexKey | HexKey[]) => {
if (pubKey) { if (pubKey) {
let ev = NEvent.ForPubKey(pubKey); let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList; ev.Kind = EventKind.ContactList;

View File

@ -1,19 +1,26 @@
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { db } from "../db"; import { db } from "../db";
import { MetadataCache } from "../db/User";
import { HexKey } from "../nostr"; import { HexKey } from "../nostr";
import { System } from "../nostr/System"; import { System } from "../nostr/System";
export default function useProfile(pubKey: HexKey | Array<HexKey>) { export default function useProfile(pubKey: HexKey | Array<HexKey> | undefined): Map<HexKey, MetadataCache> | undefined {
const user = useLiveQuery(async () => { const user = useLiveQuery(async () => {
let userList = new Map<HexKey, MetadataCache>();
if (pubKey) { if (pubKey) {
if (Array.isArray(pubKey)) { if (Array.isArray(pubKey)) {
let ret = await db.users.bulkGet(pubKey); let ret = await db.users.bulkGet(pubKey);
return ret.filter(a => a !== undefined).map(a => a!); let filtered = ret.filter(a => a !== undefined).map(a => a!);
return new Map(filtered.map(a => [a.pubkey, a]))
} else { } else {
return await db.users.get(pubKey); let ret = await db.users.get(pubKey);
if (ret) {
userList.set(ret.pubkey, ret);
}
} }
} }
return userList;
}, [pubKey]); }, [pubKey]);
useEffect(() => { useEffect(() => {

View File

@ -1,11 +1,13 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { System } from "../nostr/System"; import { System } from "../nostr/System";
import { CustomHook } from "../nostr/Connection"; import { CustomHook, StateSnapshot } from "../nostr/Connection";
const noop = (f: CustomHook) => { return () => { }; }; const noop = (f: CustomHook) => { return () => { }; };
const noopState = () => { }; const noopState = (): StateSnapshot | undefined => {
return undefined;
};
export default function useRelayState(addr: string) { export default function useRelayState(addr: string) {
let c = System.Sockets.get(addr); let c = System.Sockets.get(addr);
return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState); return useSyncExternalStore<StateSnapshot | undefined>(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState);
} }

View File

@ -1,34 +0,0 @@
import * as secp from "@noble/secp256k1";
/**
* Upload file to void.cat
* https://void.cat/swagger/index.html
* @param {File|Blob} file
* @returns
*/
export default async function VoidUpload(file) {
const buf = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buf);
let req = await fetch(`https://void.cat/upload`, {
mode: "cors",
method: "POST",
body: buf,
headers: {
"Content-Type": "application/octet-stream",
"V-Content-Type": file.type,
"V-Filename": file.name,
"V-Full-Digest": secp.utils.bytesToHex(Uint8Array.from(digest))
}
});
if (req.ok) {
let rsp = await req.json();
if (rsp.ok) {
return rsp.file;
} else {
throw rsp.errorMessage;
}
}
return null;
}

55
src/feed/VoidUpload.ts Normal file
View File

@ -0,0 +1,55 @@
import * as secp from "@noble/secp256k1";
/**
* Upload file to void.cat
* https://void.cat/swagger/index.html
*/
export default async function VoidUpload(file: File | Blob, filename: string) {
const buf = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buf);
let req = await fetch(`https://void.cat/upload`, {
mode: "cors",
method: "POST",
body: buf,
headers: {
"Content-Type": "application/octet-stream",
"V-Content-Type": file.type,
"V-Filename": filename,
"V-Full-Digest": secp.utils.bytesToHex(new Uint8Array(digest)),
"V-Description": "Upload from https://snort.social"
}
});
if (req.ok) {
let rsp: VoidUploadResponse = await req.json();
return rsp;
}
return null;
}
export type VoidUploadResponse = {
ok: boolean,
file?: VoidFile,
errorMessage?: string
}
export type VoidFile = {
id: string,
meta?: VoidFileMeta
}
export type VoidFileMeta = {
version: number,
id: string,
name?: string,
size: number,
uploaded: Date,
description?: string,
mimeType?: string,
digest?: string,
url?: string,
expires?: Date,
storage?: string,
encryptionParams?: string,
}

View File

@ -68,7 +68,7 @@ export default class Event {
*/ */
get RootPubKey() { get RootPubKey() {
let delegation = this.Tags.find(a => a.Key === "delegation"); let delegation = this.Tags.find(a => a.Key === "delegation");
if (delegation) { if (delegation?.PubKey) {
return delegation.PubKey; return delegation.PubKey;
} }
return this.PubKey; return this.PubKey;

View File

@ -4,11 +4,8 @@ import { useSelector } from "react-redux";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
// @ts-ignore
import ProfileImage from "../element/ProfileImage"; import ProfileImage from "../element/ProfileImage";
// @ts-ignore
import { bech32ToHex } from "../Util"; import { bech32ToHex } from "../Util";
// @ts-ignore
import useEventPublisher from "../feed/EventPublisher"; import useEventPublisher from "../feed/EventPublisher";
import DM from "../element/DM"; import DM from "../element/DM";

View File

@ -1,10 +1,11 @@
import "./ProfilePage.css"; import "./ProfilePage.css";
// @ts-ignore
import Nostrich from "../nostrich.jpg"; import Nostrich from "../nostrich.jpg";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faQrcode, faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons"; import { faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import useProfile from "../feed/ProfileFeed"; import useProfile from "../feed/ProfileFeed";
@ -18,25 +19,27 @@ import Copy from "../element/Copy";
import ProfilePreview from "../element/ProfilePreview"; import ProfilePreview from "../element/ProfilePreview";
import FollowersList from "../element/FollowersList"; import FollowersList from "../element/FollowersList";
import FollowsList from "../element/FollowsList"; import FollowsList from "../element/FollowsList";
import { RootState } from "../state/Store";
import { HexKey } from "../nostr";
const ProfileTab = { enum ProfileTab {
Notes: 0, Notes = "Notes",
//Reactions: 1, Reactions = "Reactions",
Followers: 2, Followers = "Followers",
Follows: 3 Follows = "Follows"
}; };
export default function ProfilePage() { export default function ProfilePage() {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const id = useMemo(() => parseId(params.id), [params]); const id = useMemo(() => parseId(params.id!), [params]);
const user = useProfile(id); const user = useProfile(id)?.get(id);
const loginPubKey = useSelector(s => s.login.publicKey); const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const follows = useSelector(s => s.login.follows); const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const isMe = loginPubKey === id; const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState(false); const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [tab, setTab] = useState(ProfileTab.Notes); const [tab, setTab] = useState(ProfileTab.Notes);
const about = Text({ content: user?.about }) const about = Text({ content: user?.about ?? "", users: new Map(), tags: [] })
const avatarUrl = (user?.picture?.length ?? 0) === 0 ? Nostrich : user?.picture const avatarUrl = (user?.picture?.length ?? 0) === 0 ? Nostrich : user?.picture
const backgroundImage = `url(${avatarUrl})` const backgroundImage = `url(${avatarUrl})`
const domain = user?.nip05 && user.nip05.split('@')[1] const domain = user?.nip05 && user.nip05.split('@')[1]
@ -46,13 +49,13 @@ export default function ProfilePage() {
}, [params]); }, [params]);
function username() { function username() {
return ( return (
<div className="name"> <div className="name">
<h2>{user?.display_name || user?.name || 'Nostrich'}</h2> <h2>{user?.display_name || user?.name || 'Nostrich'}</h2>
<Copy text={params.id} /> <Copy text={params.id || ""} />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />} {user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div> </div>
) )
} }
function bio() { function bio() {
@ -62,20 +65,20 @@ export default function ProfilePage() {
<div>{about}</div> <div>{about}</div>
<div className="links"> <div className="links">
{user?.website && ( {user?.website && (
<div className="website f-ellipsis"> <div className="website f-ellipsis">
<a href={user.website} target="_blank" rel="noreferrer">{user.website}</a> <a href={user.website} target="_blank" rel="noreferrer">{user.website}</a>
</div> </div>
)} )}
{lnurl && ( {lnurl && (
<div className="f-ellipsis" onClick={(e) => setShowLnQr(true)}> <div className="f-ellipsis" onClick={(e) => setShowLnQr(true)}>
<span className="zap"></span> <span className="zap"></span>
<span className="lnurl" > <span className="lnurl" >
{lnurl} {lnurl}
</span> </span>
</div> </div>
)} )}
</div> </div>
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} /> <LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
</div> </div>
@ -84,7 +87,7 @@ export default function ProfilePage() {
function tabContent() { function tabContent() {
switch (tab) { switch (tab) {
case ProfileTab.Notes: return <Timeline pubkeys={id} />; case ProfileTab.Notes: return <Timeline pubkeys={[id]} global={false} />;
case ProfileTab.Follows: { case ProfileTab.Follows: {
if (isMe) { if (isMe) {
return ( return (
@ -101,13 +104,12 @@ export default function ProfilePage() {
return <FollowersList pubkey={id} /> return <FollowersList pubkey={id} />
} }
} }
return null;
} }
function avatar() { function avatar() {
return ( return (
<div className="avatar-wrapper"> <div className="avatar-wrapper">
<div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}> <div style={{ ['--img-url' as any]: backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}>
</div> </div>
</div> </div>
) )
@ -150,8 +152,8 @@ export default function ProfilePage() {
)} )}
</div> </div>
<div className="tabs"> <div className="tabs">
{Object.entries(ProfileTab).map(([k, v]) => { {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => {
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div> return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
})} })}
</div> </div>
{tabContent()} {tabContent()}

View File

@ -1,4 +1,5 @@
import "./SettingsPage.css"; import "./SettingsPage.css";
// @ts-ignore
import Nostrich from "../nostrich.jpg"; import Nostrich from "../nostrich.jpg";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -14,40 +15,44 @@ import { logout, setRelays } from "../state/Login";
import { hexToBech32, openFile } from "../Util"; import { hexToBech32, openFile } from "../Util";
import Relay from "../element/Relay"; import Relay from "../element/Relay";
import Copy from "../element/Copy"; import Copy from "../element/Copy";
import { RootState } from "../state/Store";
import { HexKey, UserMetadata } from "../nostr";
import { RelaySettings } from "../nostr/Connection";
import { MetadataCache } from "../db/User";
export default function SettingsPage(props) { export default function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const id = useSelector(s => s.login.publicKey); const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector(s => s.login.privateKey); const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const relays = useSelector(s => s.login.relays); const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const dispatch = useDispatch(); const dispatch = useDispatch();
const user = useProfile(id); const user = useProfile(id)?.get(id || "");
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [name, setName] = useState(""); const [name, setName] = useState<string>();
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState<string>();
const [picture, setPicture] = useState(""); const [picture, setPicture] = useState<string>();
const [banner, setBanner] = useState(""); const [banner, setBanner] = useState<string>();
const [about, setAbout] = useState(""); const [about, setAbout] = useState<string>();
const [website, setWebsite] = useState(""); const [website, setWebsite] = useState<string>();
const [nip05, setNip05] = useState(""); const [nip05, setNip05] = useState<string>();
const [lud06, setLud06] = useState(""); const [lud06, setLud06] = useState<string>();
const [lud16, setLud16] = useState(""); const [lud16, setLud16] = useState<string>();
const [newRelay, setNewRelay] = useState(""); const [newRelay, setNewRelay] = useState<string>();
const avatarPicture = picture.length === 0 ? Nostrich : picture const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setName(user.name ?? ""); setName(user.name);
setDisplayName(user.display_name ?? "") setDisplayName(user.display_name)
setPicture(user.picture ?? ""); setPicture(user.picture);
setBanner(user.banner ?? ""); setBanner(user.banner);
setAbout(user.about ?? ""); setAbout(user.about);
setWebsite(user.website ?? ""); setWebsite(user.website);
setNip05(user.nip05 ?? ""); setNip05(user.nip05);
setLud06(user.lud06 ?? ""); setLud06(user.lud06);
setLud16(user.lud16 ?? ""); setLud16(user.lud16);
} }
}, [user]); }, [user]);
@ -65,23 +70,8 @@ export default function SettingsPage(props) {
lud16 lud16
}; };
delete userCopy["loaded"]; delete userCopy["loaded"];
delete userCopy["fromEvent"]; delete userCopy["created"];
// event top level props should not be copied into metadata (bug)
delete userCopy["pubkey"]; delete userCopy["pubkey"];
delete userCopy["sig"];
delete userCopy["pubkey"];
delete userCopy["tags"];
delete userCopy["content"];
delete userCopy["created_at"];
delete userCopy["id"];
delete userCopy["kind"]
// trim empty string fields
Object.keys(userCopy).forEach(k => {
if (userCopy[k] === "") {
delete userCopy[k];
}
});
console.debug(userCopy); console.debug(userCopy);
let ev = await publisher.metadata(userCopy); let ev = await publisher.metadata(userCopy);
@ -91,22 +81,28 @@ export default function SettingsPage(props) {
async function uploadFile() { async function uploadFile() {
let file = await openFile(); let file = await openFile();
console.log(file); if (file) {
let rsp = await VoidUpload(file); console.log(file);
if (!rsp) { let rsp = await VoidUpload(file, file.name);
throw "Upload failed, please try again later"; if (!rsp?.ok) {
throw "Upload failed, please try again later";
}
return rsp.file;
} }
return rsp
} }
async function setNewAvatar() { async function setNewAvatar() {
const rsp = await uploadFile() const rsp = await uploadFile();
setPicture(rsp.metadata.url ?? `https://void.cat/d/${rsp.id}`) if (rsp) {
setPicture(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`);
}
} }
async function setNewBanner() { async function setNewBanner() {
const rsp = await uploadFile() const rsp = await uploadFile();
setBanner(rsp.metadata.url ?? `https://void.cat/d/${rsp.id}`) if (rsp) {
setBanner(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`);
}
} }
async function saveRelays() { async function saveRelays() {
@ -171,15 +167,19 @@ export default function SettingsPage(props) {
} }
function addRelay() { function addRelay() {
return ( if ((newRelay?.length ?? 0) > 0) {
<> const parsed = new URL(newRelay!);
<h4>Add Relays</h4> const payload = { relays: { [parsed.toString()]: { read: false, write: false } }, createdAt: Math.floor(new Date().getTime() / 1000) };
<div className="flex mb10"> return (
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} /> <>
</div> <h4>Add Relays</h4>
<div className="btn mb10" onClick={() => dispatch(setRelays({ [newRelay]: { read: false, write: false } }))}>Add</div> <div className="flex mb10">
</> <input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
) </div>
<div className="btn mb10" onClick={() => dispatch(setRelays(payload))}>Add</div>
</>
)
}
} }
function settings() { function settings() {
@ -188,18 +188,18 @@ export default function SettingsPage(props) {
<> <>
<h1>Settings</h1> <h1>Settings</h1>
<div className="flex f-center image-settings"> <div className="flex f-center image-settings">
<div> <div>
<h2>Avatar</h2> <h2>Avatar</h2>
<div style={{ backgroundImage: `url(${avatarPicture})` }} className="avatar"> <div style={{ backgroundImage: `url(${avatarPicture})` }} className="avatar">
<div className="edit" onClick={() => setNewAvatar()}>Edit</div> <div className="edit" onClick={() => setNewAvatar()}>Edit</div>
</div>
</div> </div>
</div> <div>
<div> <h2>Header</h2>
<h2>Header</h2> <div style={{ backgroundImage: `url(${(banner?.length ?? 0) === 0 ? avatarPicture : banner})` }} className="banner">
<div style={{ backgroundImage: `url(${banner.length === 0 ? avatarPicture : banner})` }} className="banner"> <div className="edit" onClick={() => setNewBanner()}>Edit</div>
<div className="edit" onClick={() => setNewBanner()}>Edit</div> </div>
</div> </div>
</div>
</div> </div>
{editor()} {editor()}
</> </>

View File

@ -27,7 +27,7 @@ interface LoginStore {
/** /**
* All the logged in users relays * All the logged in users relays
*/ */
relays: any, relays: Record<string, RelaySettings>,
/** /**
* Newest relay list timestamp * Newest relay list timestamp
@ -56,7 +56,7 @@ interface LoginStore {
}; };
export interface SetRelaysPayload { export interface SetRelaysPayload {
relays: any, relays: Record<string, RelaySettings>,
createdAt: number createdAt: number
}; };

View File

@ -4,7 +4,7 @@ export const useCopy = (timeout = 2000) => {
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copy = async (text) => { const copy = async (text: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(true) setCopied(true)