forked from Kieran/snort
commit
fb1a0d7ac6
@ -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();
|
||||
|
@ -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
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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() {
|
@ -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`} />
|
||||
}
|
@ -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}`} />
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import './Hashtag.css'
|
||||
|
||||
const Hashtag = ({ children }) => {
|
||||
const Hashtag = ({ children }: any) => {
|
||||
return (
|
||||
<span className="hashtag">
|
||||
{children}
|
@ -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,
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
@ -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);
|
||||
|
@ -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>
|
||||
)
|
@ -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
90
src/element/Nip05.tsx
Normal 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
|
@ -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} />
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
103
src/element/NoteCreator.tsx
Normal file
103
src/element/NoteCreator.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPaperclip } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import "./NoteCreator.css";
|
||||
|
||||
import useEventPublisher from "../feed/EventPublisher";
|
||||
import { openFile } from "../Util";
|
||||
import VoidUpload from "../feed/VoidUpload";
|
||||
import { FileExtensionRegex } from "../Const";
|
||||
import Textarea from "../element/Textarea";
|
||||
import Event, { default as NEvent } from "../nostr/Event";
|
||||
|
||||
export interface NoteCreatorProps {
|
||||
replyTo?: NEvent,
|
||||
onSend?: Function,
|
||||
show: boolean,
|
||||
autoFocus: boolean
|
||||
}
|
||||
|
||||
export function NoteCreator(props: NoteCreatorProps) {
|
||||
const publisher = useEventPublisher();
|
||||
const [note, setNote] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [active, setActive] = useState<boolean>(false);
|
||||
|
||||
async function sendNote() {
|
||||
if (note) {
|
||||
let ev = props.replyTo ? await publisher.reply(props.replyTo, note) : await publisher.note(note);
|
||||
console.debug("Sending note: ", ev);
|
||||
publisher.broadcast(ev);
|
||||
setNote("");
|
||||
if (typeof props.onSend === "function") {
|
||||
props.onSend();
|
||||
}
|
||||
setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachFile() {
|
||||
try {
|
||||
let file = await openFile();
|
||||
if (file) {
|
||||
let rx = await VoidUpload(file, file.name);
|
||||
if (rx?.ok && rx?.file) {
|
||||
let ext = file.name.match(FileExtensionRegex);
|
||||
|
||||
// extension tricks note parser to embed the content
|
||||
let url = rx.file.meta?.url ?? `https://void.cat/d/${rx.file.id}${ext ? `.${ext[1]}` : ""}`;
|
||||
|
||||
setNote(n => `${n}\n${url}`);
|
||||
} else if (rx?.errorMessage) {
|
||||
setError(rx.errorMessage);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setError(error?.message)
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(ev: any) {
|
||||
const { value } = ev.target
|
||||
setNote(value)
|
||||
if (value) {
|
||||
setActive(true)
|
||||
} else {
|
||||
setActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
|
||||
ev.stopPropagation();
|
||||
sendNote().catch(console.warn);
|
||||
}
|
||||
|
||||
if (!props.show) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={`flex note-creator ${props.replyTo ? 'note-reply' : ''}`}>
|
||||
<div className="flex f-col mr10 f-grow">
|
||||
<Textarea
|
||||
autoFocus={props.autoFocus}
|
||||
className={`textarea ${active ? "textarea--focused" : ""}`}
|
||||
onChange={onChange}
|
||||
value={note}
|
||||
onFocus={() => setActive(true)}
|
||||
/>
|
||||
{active && note && (
|
||||
<div className="actions flex f-row">
|
||||
<div className="attachment flex f-row">
|
||||
{(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null}
|
||||
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={onSubmit}>
|
||||
{props.replyTo ? 'Reply' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
@ -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">
|
@ -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>
|
||||
);
|
@ -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`;
|
||||
}
|
@ -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>
|
@ -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>
|
||||
)
|
@ -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]);
|
@ -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`}>
|
@ -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>
|
@ -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";
|
||||
|
@ -1,29 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Event from "../nostr/Event";
|
||||
import { TaggedRawEvent, u256 } from "../nostr";
|
||||
import { default as NEvent } from "../nostr/Event";
|
||||
import EventKind from "../nostr/EventKind";
|
||||
import { eventLink } from "../Util";
|
||||
import Note from "./Note";
|
||||
import NoteGhost from "./NoteGhost";
|
||||
|
||||
export default function Thread(props) {
|
||||
export interface ThreadProps {
|
||||
this?: u256,
|
||||
notes?: TaggedRawEvent[]
|
||||
}
|
||||
export default function Thread(props: ThreadProps) {
|
||||
const thisEvent = props.this;
|
||||
|
||||
/** @type {Array<Event>} */
|
||||
const notes = props.notes?.map(a => new Event(a));
|
||||
const notes = props.notes?.map(a => new NEvent(a));
|
||||
|
||||
// root note has no thread info
|
||||
const root = useMemo(() => notes.find(a => a.Thread === null), [notes]);
|
||||
const root = useMemo(() => notes?.find(a => a.Thread === null), [notes]);
|
||||
|
||||
const chains = useMemo(() => {
|
||||
let chains = new Map();
|
||||
notes.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
|
||||
let chains = new Map<u256, NEvent[]>();
|
||||
notes?.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
|
||||
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
|
||||
if (replyTo) {
|
||||
if (!chains.has(replyTo)) {
|
||||
chains.set(replyTo, [v]);
|
||||
} else {
|
||||
chains.get(replyTo).push(v);
|
||||
chains.get(replyTo)!.push(v);
|
||||
}
|
||||
} else if (v.Tags.length > 0) {
|
||||
console.log("Not replying to anything: ", v);
|
||||
@ -34,15 +37,15 @@ export default function Thread(props) {
|
||||
}, [notes]);
|
||||
|
||||
const brokenChains = useMemo(() => {
|
||||
return Array.from(chains?.keys()).filter(a => !notes.some(b => b.Id === a));
|
||||
return Array.from(chains?.keys()).filter(a => !notes?.some(b => b.Id === a));
|
||||
}, [chains]);
|
||||
|
||||
const mentionsRoot = useMemo(() => {
|
||||
return notes.filter(a => a.Kind === EventKind.TextNote && a.Thread)
|
||||
return notes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
|
||||
}, [chains]);
|
||||
|
||||
function reactions(id, kind = EventKind.Reaction) {
|
||||
return notes?.filter(a => a.Kind === kind && a.Tags.find(a => a.Key === "e" && a.Event === id));
|
||||
function reactions(id: u256, kind = EventKind.Reaction) {
|
||||
return (notes?.filter(a => a.Kind === kind && a.Tags.find(a => a.Key === "e" && a.Event === id)) || []).map(a => a.Original!);
|
||||
}
|
||||
|
||||
function renderRoot() {
|
||||
@ -50,12 +53,12 @@ export default function Thread(props) {
|
||||
return <Note data-ev={root} reactions={reactions(root.Id)} deletion={reactions(root.Id, EventKind.Deletion)} isThread />
|
||||
} else {
|
||||
return <NoteGhost>
|
||||
Loading thread root.. ({notes.length} notes loaded)
|
||||
Loading thread root.. ({notes?.length} notes loaded)
|
||||
</NoteGhost>
|
||||
}
|
||||
}
|
||||
|
||||
function renderChain(from) {
|
||||
function renderChain(from: u256) {
|
||||
if (from && chains) {
|
||||
let replies = chains.get(from);
|
||||
if (replies) {
|
||||
@ -64,7 +67,11 @@ export default function Thread(props) {
|
||||
{replies.map(a => {
|
||||
return (
|
||||
<>
|
||||
<Note data-ev={a} key={a.Id} reactions={reactions(a.Id)} deletion={reactions(a.Id, EventKind.Deletion)} hightlight={thisEvent === a.Id} />
|
||||
<Note data-ev={a}
|
||||
key={a.Id}
|
||||
reactions={reactions(a.Id)}
|
||||
deletion={reactions(a.Id, EventKind.Deletion)}
|
||||
highlight={thisEvent === a.Id} />
|
||||
{renderChain(a.Id)}
|
||||
</>
|
||||
)
|
@ -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)}</>;
|
||||
}
|
@ -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;
|
||||
|
@ -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(() => {
|
||||
|
@ -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);
|
||||
}
|
@ -17,7 +17,7 @@ export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, global:
|
||||
|
||||
let sub = new Subscriptions();
|
||||
sub.Id = `timeline:${subTab}`;
|
||||
sub.Authors = new Set(global ? [] : pubKeys);
|
||||
sub.Authors = global ? undefined : new Set(pubKeys);
|
||||
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
|
||||
sub.Limit = 20;
|
||||
|
||||
|
@ -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
55
src/feed/VoidUpload.ts
Normal 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,
|
||||
}
|
@ -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;
|
||||
|
@ -15,42 +15,42 @@ export class Subscriptions {
|
||||
/**
|
||||
* a list of event ids or prefixes
|
||||
*/
|
||||
Ids: Set<u256> | null
|
||||
Ids?: Set<u256>
|
||||
|
||||
/**
|
||||
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
|
||||
*/
|
||||
Authors: Set<u256> | null;
|
||||
Authors?: Set<u256>;
|
||||
|
||||
/**
|
||||
* a list of a kind numbers
|
||||
*/
|
||||
Kinds: Set<EventKind> | null;
|
||||
Kinds?: Set<EventKind>;
|
||||
|
||||
/**
|
||||
* a list of event ids that are referenced in an "e" tag
|
||||
*/
|
||||
ETags: Set<u256> | null;
|
||||
ETags?: Set<u256>;
|
||||
|
||||
/**
|
||||
* a list of pubkeys that are referenced in a "p" tag
|
||||
*/
|
||||
PTags: Set<u256> | null;
|
||||
PTags?: Set<u256>;
|
||||
|
||||
/**
|
||||
* a timestamp, events must be newer than this to pass
|
||||
*/
|
||||
Since: number | null;
|
||||
Since?: number;
|
||||
|
||||
/**
|
||||
* a timestamp, events must be older than this to pass
|
||||
*/
|
||||
Until: number | null;
|
||||
Until?: number;
|
||||
|
||||
/**
|
||||
* maximum number of events to be returned in the initial query
|
||||
*/
|
||||
Limit: number | null;
|
||||
Limit?: number;
|
||||
|
||||
/**
|
||||
* Handler function for this event
|
||||
@ -79,14 +79,14 @@ export class Subscriptions {
|
||||
|
||||
constructor(sub?: RawReqFilter) {
|
||||
this.Id = uuid();
|
||||
this.Ids = sub?.ids ? new Set(sub.ids) : null;
|
||||
this.Authors = sub?.authors ? new Set(sub.authors) : null;
|
||||
this.Kinds = sub?.kinds ? new Set(sub.kinds) : null;
|
||||
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : null;
|
||||
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : null;
|
||||
this.Since = sub?.since ?? null;
|
||||
this.Until = sub?.until ?? null;
|
||||
this.Limit = sub?.limit ?? null;
|
||||
this.Ids = sub?.ids ? new Set(sub.ids) : undefined;
|
||||
this.Authors = sub?.authors ? new Set(sub.authors) : undefined;
|
||||
this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined;
|
||||
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
|
||||
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
|
||||
this.Since = sub?.since ?? undefined;
|
||||
this.Until = sub?.until ?? undefined;
|
||||
this.Limit = sub?.limit ?? undefined;
|
||||
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
|
||||
this.OnEnd = (c) => { };
|
||||
this.OrSubs = [];
|
||||
|
@ -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";
|
||||
|
@ -6,8 +6,7 @@ import { parseId } from "../Util";
|
||||
|
||||
export default function EventPage() {
|
||||
const params = useParams();
|
||||
const id = parseId(params.id);
|
||||
|
||||
const id = parseId(params.id!);
|
||||
const thread = useThreadFeed(id);
|
||||
|
||||
const filtered = useMemo(() => {
|
@ -9,15 +9,18 @@ import { System } from "../nostr/System"
|
||||
import ProfileImage from "../element/ProfileImage";
|
||||
import { init } from "../state/Login";
|
||||
import useLoginFeed from "../feed/LoginFeed";
|
||||
import { RootState } from "../state/Store";
|
||||
import { HexKey, TaggedRawEvent } from "../nostr";
|
||||
import { RelaySettings } from "../nostr/Connection";
|
||||
|
||||
export default function Layout(props) {
|
||||
export default function Layout() {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const isInit = useSelector(s => s.login.loggedOut);
|
||||
const key = useSelector(s => s.login.publicKey);
|
||||
const relays = useSelector(s => s.login.relays);
|
||||
const notifications = useSelector(s => s.login.notifications);
|
||||
const readNotifications = useSelector(s => s.login.readNotifications);
|
||||
const isInit = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut);
|
||||
const key = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
|
||||
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
|
||||
const readNotifications = useSelector<RootState, number>(s => s.login.readNotifications);
|
||||
useLoginFeed();
|
||||
|
||||
useEffect(() => {
|
||||
@ -37,7 +40,7 @@ export default function Layout(props) {
|
||||
dispatch(init());
|
||||
}, []);
|
||||
|
||||
async function goToNotifications(e) {
|
||||
async function goToNotifications(e: any) {
|
||||
e.stopPropagation();
|
||||
// request permissions to send notifications
|
||||
if ("Notification" in window && Notification.permission !== "granted") {
|
||||
@ -64,7 +67,7 @@ export default function Layout(props) {
|
||||
{unreadNotifications > 0 && (<span className="unread-count">
|
||||
{unreadNotifications > 100 ? ">99" : unreadNotifications}
|
||||
</span>)}
|
||||
<ProfileImage pubkey={key} showUsername={false} />
|
||||
<ProfileImage pubkey={key || ""} showUsername={false} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -84,7 +87,7 @@ export default function Layout(props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Outlet/>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -6,11 +6,13 @@ import * as secp from '@noble/secp256k1';
|
||||
import { setPrivateKey, setPublicKey } from "../state/Login";
|
||||
import { EmailRegex } from "../Const";
|
||||
import { bech32ToHex } from "../Util";
|
||||
import { RootState } from "../state/Store";
|
||||
import { HexKey } from "../nostr";
|
||||
|
||||
export default function LoginPage() {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const publicKey = useSelector(s => s.login.publicKey);
|
||||
const publicKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@ -20,7 +22,7 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [publicKey]);
|
||||
|
||||
async function getNip05PubKey(addr) {
|
||||
async function getNip05PubKey(addr: string) {
|
||||
let [username, domain] = addr.split("@");
|
||||
let rsp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(username)}`);
|
||||
if (rsp.ok) {
|
@ -1,8 +1,7 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { RecommendedFollows } from "../Const";
|
||||
import ProfilePreview from "../element/ProfilePreview";
|
||||
|
||||
export default function NewUserPage(props) {
|
||||
export default function NewUserPage() {
|
||||
|
||||
function followSomebody() {
|
||||
return (
|
@ -3,14 +3,16 @@ import { useDispatch, useSelector } from "react-redux"
|
||||
import Note from "../element/Note";
|
||||
import NoteReaction from "../element/NoteReaction";
|
||||
import useSubscription from "../feed/Subscription";
|
||||
import { TaggedRawEvent } from "../nostr";
|
||||
import Event from "../nostr/Event";
|
||||
import EventKind from "../nostr/EventKind";
|
||||
import { Subscriptions } from "../nostr/Subscriptions";
|
||||
import { markNotificationsRead } from "../state/Login";
|
||||
import { RootState } from "../state/Store";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const dispatch = useDispatch();
|
||||
const notifications = useSelector(s => s.login.notifications);
|
||||
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(markNotificationsRead());
|
||||
@ -21,7 +23,7 @@ export default function NotificationsPage() {
|
||||
.map(a => {
|
||||
let ev = new Event(a);
|
||||
return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||
})
|
||||
}).filter(a => a !== undefined).map(a => a!);
|
||||
}, [notifications]);
|
||||
|
||||
const subEvents = useMemo(() => {
|
||||
@ -50,7 +52,7 @@ export default function NotificationsPage() {
|
||||
{sorted?.map(a => {
|
||||
if (a.kind === EventKind.TextNote) {
|
||||
let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id));
|
||||
return <Note data={a} key={a.id} reactions={reactions} />
|
||||
return <Note data={a} key={a.id} reactions={reactions} deletion={[]}/>
|
||||
} else if (a.kind === EventKind.Reaction) {
|
||||
let ev = new Event(a);
|
||||
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
@ -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()}
|
@ -4,7 +4,8 @@ import { Link } from "react-router-dom";
|
||||
import { NoteCreator } from "../element/NoteCreator";
|
||||
import Timeline from "../element/Timeline";
|
||||
import { useState } from "react";
|
||||
import useScroll from "../useScroll";
|
||||
import { RootState } from "../state/Store";
|
||||
import { HexKey } from "../nostr";
|
||||
|
||||
const RootTab = {
|
||||
Follows: 0,
|
||||
@ -12,9 +13,8 @@ const RootTab = {
|
||||
};
|
||||
|
||||
export default function RootPage() {
|
||||
const [loggedOut, pubKey, follows] = useSelector(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]);
|
||||
const [loggedOut, pubKey, follows] = useSelector<RootState, [boolean | undefined, HexKey | undefined, HexKey[]]>(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]);
|
||||
const [tab, setTab] = useState(RootTab.Follows);
|
||||
const [eop] = useScroll();
|
||||
|
||||
function followHints() {
|
||||
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
|
||||
@ -27,7 +27,7 @@ export default function RootPage() {
|
||||
return (
|
||||
<>
|
||||
{pubKey ? <>
|
||||
<NoteCreator show={true}/>
|
||||
<NoteCreator show={true} autoFocus={true} />
|
||||
<div className="tabs root-tabs">
|
||||
<div className={`root-tab f-1 ${tab === RootTab.Follows ? "active" : ""}`} onClick={() => setTab(RootTab.Follows)}>
|
||||
Follows
|
@ -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()}
|
||||
</>
|
@ -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
|
||||
};
|
||||
|
||||
|
@ -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)
|
Loading…
Reference in New Issue
Block a user