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

View File

@ -3,7 +3,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
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 sliceLength = maxSize / 2
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><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div>
<div className="w-max">
<Text content={content} />
<Text content={content} tags={[]} users={new Map()} />
</div>
</div>
)

View File

@ -2,28 +2,33 @@ 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 default function FollowButton(props) {
export interface FollowButtonProps {
pubkey: HexKey,
className?: string,
}
export default function FollowButton(props: FollowButtonProps) {
const pubkey = props.pubkey;
const publiser = useEventPublisher();
const follows = useSelector(s => s.login.follows);
let isFollowing = follows?.includes(pubkey) ?? false;
const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button`
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) {
async function follow(pubkey: HexKey) {
let ev = await publiser.addFollow(pubkey);
publiser.broadcast(ev);
}
async function unfollow(pubkey) {
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" />
<FontAwesomeIcon icon={isFollowing ? faUserMinus : faUserPlus} size="lg" />
</div>
)
}

View File

@ -1,7 +1,12 @@
import useEventPublisher from "../feed/EventPublisher";
import { HexKey } from "../nostr";
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();
async function followAll() {

View File

@ -1,9 +1,14 @@
import { useMemo } from "react";
import useFollowersFeed from "../feed/FollowersFeed";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind";
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 pubkeys = useMemo(() => {
@ -11,5 +16,5 @@ export default function FollowersList({ pubkey }) {
return [...new Set(contactLists?.map(a => a.pubkey))];
}, [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 useFollowsFeed from "../feed/FollowsFeed";
import { HexKey } from "../nostr";
import EventKind from "../nostr/EventKind";
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 pubkeys = useMemo(() => {
@ -12,5 +17,5 @@ export default function FollowsList({ pubkey }) {
return [...new Set(pTags?.flat())];
}, [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'
const Hashtag = ({ children }) => {
const Hashtag = ({ children }: any) => {
return (
<span className="hashtag">
{children}

View File

@ -1,11 +1,15 @@
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 "./NoteTime";
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 [showInvoice, setShowInvoice] = useState(false);
@ -13,10 +17,10 @@ export default function Invoice(props) {
try {
let parsed = invoiceDecode(invoice);
let amount = parseInt(parsed.sections.find(a => a.name === "amount")?.value);
let timestamp = parseInt(parsed.sections.find(a => a.name === "timestamp")?.value);
let expire = parseInt(parsed.sections.find(a => a.name === "expiry")?.value);
let description = parsed.sections.find(a => a.name === "description")?.value;
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,

View File

@ -5,31 +5,67 @@ import Modal from "./Modal";
import QrCode from "./QrCode";
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 service = props.svc;
const show = props.show || false;
const amounts = [50, 100, 500, 1_000, 5_000, 10_000];
const [payService, setPayService] = useState("");
const [amount, setAmount] = useState(0);
const [customAmount, setCustomAmount] = useState(0);
const [invoice, setInvoice] = useState(null);
const [comment, setComment] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(null);
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))
.then(a => setPayService(a!))
.catch(() => setError("Failed to load LNURL service"));
} else {
setPayService("");
setError("");
setInvoice(props.invoice ? { pr: props.invoice } : null);
setAmount(0);
setComment("");
setSuccess(null);
setPayService(undefined);
setError(undefined);
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
setAmount(undefined);
setComment(undefined);
setSuccess(undefined);
}
}, [show, service]);
@ -44,7 +80,7 @@ export default function LNURLTip(props) {
const metadata = useMemo(() => {
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 image = meta.find(a => a[0] === "image/png;base64");
return {
@ -55,37 +91,40 @@ export default function LNURLTip(props) {
return null;
}, [payService]);
const selectAmount = (a) => {
setError("");
setInvoice(null);
const selectAmount = (a: number) => {
setError(undefined);
setInvoice(undefined);
setAmount(a);
};
async function fetchJson(url) {
async function fetchJson<T>(url: string) {
let rsp = await fetch(url);
if (rsp.ok) {
let data = await rsp.json();
let data: T = await rsp.json();
console.log(data);
setError("");
setError(undefined);
return data;
}
return null;
}
async function loadService() {
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]}`);
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 === 0) return null;
const url = `${payService.callback}?amount=${parseInt(amount * 1000)}&comment=${encodeURIComponent(comment)}`;
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) {
@ -110,21 +149,21 @@ export default function LNURLTip(props) {
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(e.target.value)} />
<div className="btn" onClick={() => selectAmount(customAmount)}>Confirm</div>
<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();
if (!window.webln!.enabled) {
await window.webln!.enable();
}
let res = await window.webln.sendPayment(invoice.pr);
let res = await window.webln!.sendPayment(invoice!.pr);
console.log(res);
setSuccess(invoice.successAction || {});
} catch (e) {
setSuccess(invoice!.successAction || {});
} catch (e: any) {
setError(e.toString());
console.warn(e);
}
@ -145,7 +184,7 @@ export default function LNURLTip(props) {
<>
<div className="f-ellipsis mb10">{metadata?.description ?? service}</div>
<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}
</div>
<div className="mb10">
@ -158,7 +197,7 @@ export default function LNURLTip(props) {
</span> : null}
</div>
{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 { useInView } from 'react-intersection-observer';
export default function LazyImage(props) {
export default function LazyImage(props: any) {
const { ref, inView, entry } = useInView();
const [shown, setShown] = useState(false);

View File

@ -1,7 +1,13 @@
import "./Modal.css";
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 || (() => { });
useEffect(() => {
@ -10,7 +16,7 @@ export default function Modal(props) {
}, []);
return (
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(e); }}>
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}>
{props.children}
</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 { useNavigate } from "react-router-dom";
import Event from "../nostr/Event";
import { default as NEvent } from "../nostr/Event";
import ProfileImage from "./ProfileImage";
import Text from "./Text";
import { eventLink, hexToBech32 } from "../Util";
@ -10,11 +10,26 @@ import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
import EventKind from "../nostr/EventKind";
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 { data, isThread, reactions, deletion, hightlight, options: opt, ["data-ev"]: parsedEvent } = props
const ev = useMemo(() => parsedEvent ?? new Event(data), [data]);
const { data, isThread, reactions, deletion, 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);
@ -31,10 +46,10 @@ export default function Note(props) {
if (deletion?.length > 0) {
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]);
function goToEvent(e, id) {
function goToEvent(e: any, id: u256) {
if (!window.location.pathname.startsWith("/e/")) {
e.stopPropagation();
navigate(eventLink(id));
@ -48,13 +63,21 @@ export default function Note(props) {
const maxMentions = 2;
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))
.sort((a, b) => a.startsWith("npub") ? 1 : -1);
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 : hexToBech32("note", replyId)?.substring(0, 12)}
{(pubMentions?.length ?? 0) > 0 ? pubMentions : replyId ? hexToBech32("note", replyId)?.substring(0, 12) : ""}
</div>
)
}
@ -71,10 +94,10 @@ export default function Note(props) {
}
return (
<div className={`note ${hightlight ? "active" : ""} ${isThread ? "thread" : ""}`}>
<div className={`note ${highlight ? "active" : ""} ${isThread ? "thread" : ""}`}>
{options.showHeader ?
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag()} />
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
{options.showTime ?
<div className="info">
<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 LNURLTip from "./LNURLTip";
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 ev = props.ev;
const login = useSelector(s => s.login.publicKey);
const author = useProfile(ev.RootPubKey);
const login = useSelector<RootState, string | 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 groupReactions = useMemo(() => {
return reactions?.reduce((acc, { Content }) => {
let r = normalizeReaction(Content ?? "");
return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(content ?? "");
const amount = acc[r] || 0
return { ...acc, [r]: amount + 1 }
}, {
@ -31,11 +39,11 @@ export default function NoteFooter(props) {
});
}, [reactions]);
function hasReacted(emoji) {
return reactions?.find(({ PubKey, Content }) => Content === emoji && PubKey === login)
function hasReacted(emoji: string) {
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);
publisher.broadcast(evLike);
}
@ -66,7 +74,7 @@ export default function NoteFooter(props) {
return null;
}
function reactionIcon(content, reacted) {
function reactionIcon(content: string, reacted: boolean) {
switch (content) {
case Reaction.Positive: {
return <FontAwesomeIcon color={reacted ? "red" : "currentColor"} icon={faHeart} />;
@ -108,10 +116,10 @@ export default function NoteFooter(props) {
<NoteCreator
autoFocus={true}
replyTo={ev}
onSend={(e) => setReply(false)}
onSend={() => setReply(false)}
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 ProfileImage from "./ProfileImage";
export default function NoteGhost(props) {
export default function NoteGhost(props: any) {
return (
<div className="note">
<div className="header">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,23 +6,33 @@ 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) {
export default function Relay(props: RelayProps) {
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 name = useMemo(() => new URL(props.addr).host, [props.addr]);
const [showExtra, setShowExtra] = useState(false);
function configure(o) {
function configure(o: RelaySettings) {
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 (
<>
<div className={`relay w-max`}>

View File

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

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";
// @ts-expect-error
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 { db } from "../db";
import { MetadataCache } from "../db/User";

View File

@ -1,16 +1,22 @@
import { useMemo } from "react";
import useTimelineFeed from "../feed/TimelineFeed";
import { HexKey, TaggedRawEvent, u256 } from "../nostr";
import EventKind from "../nostr/EventKind";
import Note from "./Note";
import NoteReaction from "./NoteReaction";
export interface TimelineProps {
global: boolean,
pubkeys: HexKey[]
}
/**
* A list of notes by pubkeys
*/
export default function Timeline({ global, pubkeys }) {
export default function Timeline({ global, pubkeys }: TimelineProps) {
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));
}
@ -18,7 +24,7 @@ export default function Timeline({ global, pubkeys }) {
return feed.main?.sort((a, b) => b.created_at - a.created_at);
}, [feed]);
function eventElement(e) {
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.TextNote: {
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);
}
},
addFollow: async (pkAdd: HexKey) => {
addFollow: async (pkAdd: HexKey | HexKey[]) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.ContactList;

View File

@ -1,19 +1,26 @@
import { useLiveQuery } from "dexie-react-hooks";
import { useEffect, useMemo } from "react";
import { db } from "../db";
import { MetadataCache } from "../db/User";
import { HexKey } from "../nostr";
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 () => {
let userList = new Map<HexKey, MetadataCache>();
if (pubKey) {
if (Array.isArray(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 {
return await db.users.get(pubKey);
let ret = await db.users.get(pubKey);
if (ret) {
userList.set(ret.pubkey, ret);
}
}
}
return userList;
}, [pubKey]);
useEffect(() => {

View File

@ -1,11 +1,13 @@
import { useSyncExternalStore } from "react";
import { System } from "../nostr/System";
import { CustomHook } from "../nostr/Connection";
import { CustomHook, StateSnapshot } from "../nostr/Connection";
const noop = (f: CustomHook) => { return () => { }; };
const noopState = () => { };
const noopState = (): StateSnapshot | undefined => {
return undefined;
};
export default function useRelayState(addr: string) {
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() {
let delegation = this.Tags.find(a => a.Key === "delegation");
if (delegation) {
if (delegation?.PubKey) {
return delegation.PubKey;
}
return this.PubKey;

View File

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

View File

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

View File

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

View File

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

View File

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