More TSX #74
@ -2,7 +2,7 @@ import * as secp from "@noble/secp256k1";
|
|||||||
import { bech32 } from "bech32";
|
import { bech32 } from "bech32";
|
||||||
import { HexKey, u256 } from "./nostr";
|
import { HexKey, u256 } from "./nostr";
|
||||||
|
|
||||||
export async function openFile() {
|
export async function openFile(): Promise<File | undefined> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let elm = document.createElement("input");
|
let elm = document.createElement("input");
|
||||||
elm.type = "file";
|
elm.type = "file";
|
||||||
@ -10,6 +10,8 @@ export async function openFile() {
|
|||||||
let elm = e.target as HTMLInputElement;
|
let elm = e.target as HTMLInputElement;
|
||||||
if (elm.files) {
|
if (elm.files) {
|
||||||
resolve(elm.files[0]);
|
resolve(elm.files[0]);
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
elm.click();
|
elm.click();
|
||||||
|
@ -3,7 +3,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
|
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useCopy } from "../useCopy";
|
import { useCopy } from "../useCopy";
|
||||||
|
|
||||||
export default function Copy({ text, maxSize = 32 }) {
|
export interface CopyProps {
|
||||||
|
text: string,
|
||||||
|
maxSize?: number
|
||||||
|
}
|
||||||
|
export default function Copy({ text, maxSize = 32 }: CopyProps) {
|
||||||
const { copy, copied, error } = useCopy();
|
const { copy, copied, error } = useCopy();
|
||||||
const sliceLength = maxSize / 2
|
const sliceLength = maxSize / 2
|
||||||
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}:${text.slice(-sliceLength)}` : text
|
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}:${text.slice(-sliceLength)}` : text
|
@ -40,7 +40,7 @@ export default function DM(props: DMProps) {
|
|||||||
<div className={`flex dm f-col${props.data.pubkey === pubKey ? " me" : ""}`} ref={ref}>
|
<div className={`flex dm f-col${props.data.pubkey === pubKey ? " me" : ""}`} ref={ref}>
|
||||||
<div><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div>
|
<div><NoteTime from={props.data.created_at * 1000} fallback={'Just now'} /></div>
|
||||||
<div className="w-max">
|
<div className="w-max">
|
||||||
<Text content={content} />
|
<Text content={content} tags={[]} users={new Map()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -2,21 +2,26 @@ import { useSelector } from "react-redux";
|
|||||||
import useEventPublisher from "../feed/EventPublisher";
|
import useEventPublisher from "../feed/EventPublisher";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons";
|
import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
|
import { RootState } from "../state/Store";
|
||||||
|
|
||||||
export default function FollowButton(props) {
|
export interface FollowButtonProps {
|
||||||
|
pubkey: HexKey,
|
||||||
|
className?: string,
|
||||||
|
}
|
||||||
|
export default function FollowButton(props: FollowButtonProps) {
|
||||||
const pubkey = props.pubkey;
|
const pubkey = props.pubkey;
|
||||||
const publiser = useEventPublisher();
|
const publiser = useEventPublisher();
|
||||||
const follows = useSelector(s => s.login.follows);
|
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
|
||||||
let isFollowing = follows?.includes(pubkey) ?? false;
|
|
||||||
const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button`
|
const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button`
|
||||||
const className = props.className ? `${baseClassName} ${props.className}` : `${baseClassName}`;
|
const className = props.className ? `${baseClassName} ${props.className}` : `${baseClassName}`;
|
||||||
|
|
||||||
async function follow(pubkey) {
|
async function follow(pubkey: HexKey) {
|
||||||
let ev = await publiser.addFollow(pubkey);
|
let ev = await publiser.addFollow(pubkey);
|
||||||
publiser.broadcast(ev);
|
publiser.broadcast(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unfollow(pubkey) {
|
async function unfollow(pubkey: HexKey) {
|
||||||
let ev = await publiser.removeFollow(pubkey);
|
let ev = await publiser.removeFollow(pubkey);
|
||||||
publiser.broadcast(ev);
|
publiser.broadcast(ev);
|
||||||
}
|
}
|
@ -1,7 +1,12 @@
|
|||||||
import useEventPublisher from "../feed/EventPublisher";
|
import useEventPublisher from "../feed/EventPublisher";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
import ProfilePreview from "./ProfilePreview";
|
import ProfilePreview from "./ProfilePreview";
|
||||||
|
|
||||||
export default function FollowListBase({ pubkeys, title}) {
|
export interface FollowListBaseProps {
|
||||||
|
pubkeys: HexKey[],
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) {
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
|
|
||||||
async function followAll() {
|
async function followAll() {
|
@ -1,9 +1,14 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import useFollowersFeed from "../feed/FollowersFeed";
|
import useFollowersFeed from "../feed/FollowersFeed";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import FollowListBase from "./FollowListBase";
|
import FollowListBase from "./FollowListBase";
|
||||||
|
|
||||||
export default function FollowersList({ pubkey }) {
|
export interface FollowersListProps {
|
||||||
|
pubkey: HexKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FollowersList({ pubkey }: FollowersListProps) {
|
||||||
const feed = useFollowersFeed(pubkey);
|
const feed = useFollowersFeed(pubkey);
|
||||||
|
|
||||||
const pubkeys = useMemo(() => {
|
const pubkeys = useMemo(() => {
|
||||||
@ -11,5 +16,5 @@ export default function FollowersList({ pubkey }) {
|
|||||||
return [...new Set(contactLists?.map(a => a.pubkey))];
|
return [...new Set(contactLists?.map(a => a.pubkey))];
|
||||||
}, [feed]);
|
}, [feed]);
|
||||||
|
|
||||||
return <FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`}/>
|
return <FollowListBase pubkeys={pubkeys} title={`${pubkeys?.length} followers`} />
|
||||||
}
|
}
|
@ -1,9 +1,14 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import useFollowsFeed from "../feed/FollowsFeed";
|
import useFollowsFeed from "../feed/FollowsFeed";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import FollowListBase from "./FollowListBase";
|
import FollowListBase from "./FollowListBase";
|
||||||
|
|
||||||
export default function FollowsList({ pubkey }) {
|
export interface FollowsListProps {
|
||||||
|
pubkey: HexKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FollowsList({ pubkey }: FollowsListProps) {
|
||||||
const feed = useFollowsFeed(pubkey);
|
const feed = useFollowsFeed(pubkey);
|
||||||
|
|
||||||
const pubkeys = useMemo(() => {
|
const pubkeys = useMemo(() => {
|
||||||
@ -12,5 +17,5 @@ export default function FollowsList({ pubkey }) {
|
|||||||
return [...new Set(pTags?.flat())];
|
return [...new Set(pTags?.flat())];
|
||||||
}, [feed]);
|
}, [feed]);
|
||||||
|
|
||||||
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`}/>
|
return <FollowListBase pubkeys={pubkeys} title={`Following ${pubkeys?.length}`} />
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import './Hashtag.css'
|
import './Hashtag.css'
|
||||||
|
|
||||||
const Hashtag = ({ children }) => {
|
const Hashtag = ({ children }: any) => {
|
||||||
return (
|
return (
|
||||||
<span className="hashtag">
|
<span className="hashtag">
|
||||||
{children}
|
{children}
|
@ -1,11 +1,15 @@
|
|||||||
import "./Invoice.css";
|
import "./Invoice.css";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
// @ts-expect-error
|
||||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import NoteTime from "./NoteTime";
|
import NoteTime from "./NoteTime";
|
||||||
import LNURLTip from "./LNURLTip";
|
import LNURLTip from "./LNURLTip";
|
||||||
|
|
||||||
export default function Invoice(props) {
|
export interface InvoiceProps {
|
||||||
|
invoice: string
|
||||||
|
}
|
||||||
|
export default function Invoice(props: InvoiceProps) {
|
||||||
const invoice = props.invoice;
|
const invoice = props.invoice;
|
||||||
const [showInvoice, setShowInvoice] = useState(false);
|
const [showInvoice, setShowInvoice] = useState(false);
|
||||||
|
|
||||||
@ -13,10 +17,10 @@ export default function Invoice(props) {
|
|||||||
try {
|
try {
|
||||||
let parsed = invoiceDecode(invoice);
|
let parsed = invoiceDecode(invoice);
|
||||||
|
|
||||||
let amount = parseInt(parsed.sections.find(a => a.name === "amount")?.value);
|
let amount = parseInt(parsed.sections.find((a: any) => a.name === "amount")?.value);
|
||||||
let timestamp = parseInt(parsed.sections.find(a => a.name === "timestamp")?.value);
|
let timestamp = parseInt(parsed.sections.find((a: any) => a.name === "timestamp")?.value);
|
||||||
let expire = parseInt(parsed.sections.find(a => a.name === "expiry")?.value);
|
let expire = parseInt(parsed.sections.find((a: any) => a.name === "expiry")?.value);
|
||||||
let description = parsed.sections.find(a => a.name === "description")?.value;
|
let description = parsed.sections.find((a: any) => a.name === "description")?.value;
|
||||||
let ret = {
|
let ret = {
|
||||||
amount: !isNaN(amount) ? (amount / 1000) : 0,
|
amount: !isNaN(amount) ? (amount / 1000) : 0,
|
||||||
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
@ -5,31 +5,67 @@ import Modal from "./Modal";
|
|||||||
import QrCode from "./QrCode";
|
import QrCode from "./QrCode";
|
||||||
import Copy from "./Copy";
|
import Copy from "./Copy";
|
||||||
|
|
||||||
export default function LNURLTip(props) {
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
webln?: {
|
||||||
|
enabled: boolean,
|
||||||
|
enable: () => Promise<void>,
|
||||||
|
sendPayment: (pr: string) => Promise<any>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LNURLService {
|
||||||
|
minSendable?: number,
|
||||||
|
maxSendable?: number,
|
||||||
|
metadata: string,
|
||||||
|
callback: string,
|
||||||
|
commentAllowed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LNURLInvoice {
|
||||||
|
pr: string,
|
||||||
|
successAction?: LNURLSuccessAction
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LNURLSuccessAction {
|
||||||
|
description?: string,
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LNURLTipProps {
|
||||||
|
onClose?: () => void,
|
||||||
|
svc?: string,
|
||||||
|
show?: boolean,
|
||||||
|
invoice?: string, // shortcut to invoice qr tab
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LNURLTip(props: LNURLTipProps) {
|
||||||
const onClose = props.onClose || (() => { });
|
const onClose = props.onClose || (() => { });
|
||||||
const service = props.svc;
|
const service = props.svc;
|
||||||
const show = props.show || false;
|
const show = props.show || false;
|
||||||
const amounts = [50, 100, 500, 1_000, 5_000, 10_000];
|
const amounts = [50, 100, 500, 1_000, 5_000, 10_000, 50_000];
|
||||||
const [payService, setPayService] = useState("");
|
const [payService, setPayService] = useState<LNURLService>();
|
||||||
const [amount, setAmount] = useState(0);
|
const [amount, setAmount] = useState<number>();
|
||||||
const [customAmount, setCustomAmount] = useState(0);
|
const [customAmount, setCustomAmount] = useState<number>();
|
||||||
const [invoice, setInvoice] = useState(null);
|
const [invoice, setInvoice] = useState<LNURLInvoice>();
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState<string>();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState<string>();
|
||||||
const [success, setSuccess] = useState(null);
|
const [success, setSuccess] = useState<LNURLSuccessAction>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show && !props.invoice) {
|
if (show && !props.invoice) {
|
||||||
loadService()
|
loadService()
|
||||||
.then(a => setPayService(a))
|
.then(a => setPayService(a!))
|
||||||
.catch(() => setError("Failed to load LNURL service"));
|
.catch(() => setError("Failed to load LNURL service"));
|
||||||
} else {
|
} else {
|
||||||
setPayService("");
|
setPayService(undefined);
|
||||||
setError("");
|
setError(undefined);
|
||||||
setInvoice(props.invoice ? { pr: props.invoice } : null);
|
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
|
||||||
setAmount(0);
|
setAmount(undefined);
|
||||||
setComment("");
|
setComment(undefined);
|
||||||
setSuccess(null);
|
setSuccess(undefined);
|
||||||
}
|
}
|
||||||
}, [show, service]);
|
}, [show, service]);
|
||||||
|
|
||||||
@ -44,7 +80,7 @@ export default function LNURLTip(props) {
|
|||||||
|
|
||||||
const metadata = useMemo(() => {
|
const metadata = useMemo(() => {
|
||||||
if (payService) {
|
if (payService) {
|
||||||
let meta = JSON.parse(payService.metadata);
|
let meta: string[][] = JSON.parse(payService.metadata);
|
||||||
let desc = meta.find(a => a[0] === "text/plain");
|
let desc = meta.find(a => a[0] === "text/plain");
|
||||||
let image = meta.find(a => a[0] === "image/png;base64");
|
let image = meta.find(a => a[0] === "image/png;base64");
|
||||||
return {
|
return {
|
||||||
@ -55,24 +91,25 @@ export default function LNURLTip(props) {
|
|||||||
return null;
|
return null;
|
||||||
}, [payService]);
|
}, [payService]);
|
||||||
|
|
||||||
const selectAmount = (a) => {
|
const selectAmount = (a: number) => {
|
||||||
setError("");
|
setError(undefined);
|
||||||
setInvoice(null);
|
setInvoice(undefined);
|
||||||
setAmount(a);
|
setAmount(a);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchJson(url) {
|
async function fetchJson<T>(url: string) {
|
||||||
let rsp = await fetch(url);
|
let rsp = await fetch(url);
|
||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
||||||
let data = await rsp.json();
|
let data: T = await rsp.json();
|
||||||
console.log(data);
|
console.log(data);
|
||||||
setError("");
|
setError(undefined);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadService() {
|
async function loadService(): Promise<LNURLService | null> {
|
||||||
|
if (service) {
|
||||||
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
let isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
||||||
if (isServiceUrl) {
|
if (isServiceUrl) {
|
||||||
let serviceUrl = bech32ToText(service);
|
let serviceUrl = bech32ToText(service);
|
||||||
@ -82,10 +119,12 @@ export default function LNURLTip(props) {
|
|||||||
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
|
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInvoice() {
|
async function loadInvoice() {
|
||||||
if (amount === 0) return null;
|
if (!amount || !payService) return null;
|
||||||
const url = `${payService.callback}?amount=${parseInt(amount * 1000)}&comment=${encodeURIComponent(comment)}`;
|
const url = `${payService.callback}?amount=${Math.floor(amount * 1000)}${comment ? `&comment=${encodeURIComponent(comment)}` : ""}`;
|
||||||
try {
|
try {
|
||||||
let rsp = await fetch(url);
|
let rsp = await fetch(url);
|
||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
||||||
@ -110,21 +149,21 @@ export default function LNURLTip(props) {
|
|||||||
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
let max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
||||||
return (
|
return (
|
||||||
<div className="flex mb10">
|
<div className="flex mb10">
|
||||||
<input type="number" min={min} max={max} className="f-grow mr10" value={customAmount} onChange={(e) => setCustomAmount(e.target.value)} />
|
<input type="number" min={min} max={max} className="f-grow mr10" value={customAmount} onChange={(e) => setCustomAmount(parseInt(e.target.value))} />
|
||||||
<div className="btn" onClick={() => selectAmount(customAmount)}>Confirm</div>
|
<div className="btn" onClick={() => selectAmount(customAmount!)}>Confirm</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function payWebLN() {
|
async function payWebLN() {
|
||||||
try {
|
try {
|
||||||
if (!window.webln.enabled) {
|
if (!window.webln!.enabled) {
|
||||||
await window.webln.enable();
|
await window.webln!.enable();
|
||||||
}
|
}
|
||||||
let res = await window.webln.sendPayment(invoice.pr);
|
let res = await window.webln!.sendPayment(invoice!.pr);
|
||||||
console.log(res);
|
console.log(res);
|
||||||
setSuccess(invoice.successAction || {});
|
setSuccess(invoice!.successAction || {});
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
setError(e.toString());
|
setError(e.toString());
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
}
|
}
|
||||||
@ -145,7 +184,7 @@ export default function LNURLTip(props) {
|
|||||||
<>
|
<>
|
||||||
<div className="f-ellipsis mb10">{metadata?.description ?? service}</div>
|
<div className="f-ellipsis mb10">{metadata?.description ?? service}</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{payService?.commentAllowed > 0 ?
|
{(payService?.commentAllowed ?? 0) > 0 ?
|
||||||
<input type="text" placeholder="Comment" className="mb10 f-grow" maxLength={payService?.commentAllowed} onChange={(e) => setComment(e.target.value)} /> : null}
|
<input type="text" placeholder="Comment" className="mb10 f-grow" maxLength={payService?.commentAllowed} onChange={(e) => setComment(e.target.value)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb10">
|
<div className="mb10">
|
||||||
@ -158,7 +197,7 @@ export default function LNURLTip(props) {
|
|||||||
</span> : null}
|
</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{amount === -1 ? custom() : null}
|
{amount === -1 ? custom() : null}
|
||||||
{amount > 0 ? <div className="btn mb10" onClick={() => loadInvoice()}>Get Invoice</div> : null}
|
{(amount ?? 0) > 0 ? <div className="btn mb10" onClick={() => loadInvoice()}>Get Invoice</div> : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
export default function LazyImage(props) {
|
export default function LazyImage(props: any) {
|
||||||
const { ref, inView, entry } = useInView();
|
const { ref, inView, entry } = useInView();
|
||||||
const [shown, setShown] = useState(false);
|
const [shown, setShown] = useState(false);
|
||||||
|
|
@ -1,7 +1,13 @@
|
|||||||
import "./Modal.css";
|
import "./Modal.css";
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
export default function Modal(props) {
|
export interface ModalProps {
|
||||||
|
onClose?: () => void,
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal(props: ModalProps) {
|
||||||
const onClose = props.onClose || (() => { });
|
const onClose = props.onClose || (() => { });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -10,7 +16,7 @@ export default function Modal(props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(e); }}>
|
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
@ -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 { useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import Event from "../nostr/Event";
|
import { default as NEvent } from "../nostr/Event";
|
||||||
import ProfileImage from "./ProfileImage";
|
import ProfileImage from "./ProfileImage";
|
||||||
import Text from "./Text";
|
import Text from "./Text";
|
||||||
import { eventLink, hexToBech32 } from "../Util";
|
import { eventLink, hexToBech32 } from "../Util";
|
||||||
@ -10,11 +10,26 @@ import NoteFooter from "./NoteFooter";
|
|||||||
import NoteTime from "./NoteTime";
|
import NoteTime from "./NoteTime";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import useProfile from "../feed/ProfileFeed";
|
import useProfile from "../feed/ProfileFeed";
|
||||||
|
import { TaggedRawEvent, u256 } from "../nostr";
|
||||||
|
|
||||||
export default function Note(props) {
|
export interface NoteProps {
|
||||||
|
data?: TaggedRawEvent,
|
||||||
|
isThread?: boolean,
|
||||||
|
reactions: TaggedRawEvent[],
|
||||||
|
deletion: TaggedRawEvent[],
|
||||||
|
highlight?: boolean,
|
||||||
|
options?: {
|
||||||
|
showHeader?: boolean,
|
||||||
|
showTime?: boolean,
|
||||||
|
showFooter?: boolean
|
||||||
|
},
|
||||||
|
["data-ev"]?: NEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Note(props: NoteProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isThread, reactions, deletion, hightlight, options: opt, ["data-ev"]: parsedEvent } = props
|
const { data, isThread, reactions, deletion, highlight, options: opt, ["data-ev"]: parsedEvent } = props
|
||||||
const ev = useMemo(() => parsedEvent ?? new Event(data), [data]);
|
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||||
|
|
||||||
const users = useProfile(pubKeys);
|
const users = useProfile(pubKeys);
|
||||||
@ -31,10 +46,10 @@ export default function Note(props) {
|
|||||||
if (deletion?.length > 0) {
|
if (deletion?.length > 0) {
|
||||||
return (<b className="error">Deleted</b>);
|
return (<b className="error">Deleted</b>);
|
||||||
}
|
}
|
||||||
return <Text content={body} tags={ev.Tags} users={users || []} />;
|
return <Text content={body} tags={ev.Tags} users={users || new Map()} />;
|
||||||
}, [props]);
|
}, [props]);
|
||||||
|
|
||||||
function goToEvent(e, id) {
|
function goToEvent(e: any, id: u256) {
|
||||||
if (!window.location.pathname.startsWith("/e/")) {
|
if (!window.location.pathname.startsWith("/e/")) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(eventLink(id));
|
navigate(eventLink(id));
|
||||||
@ -48,13 +63,21 @@ export default function Note(props) {
|
|||||||
|
|
||||||
const maxMentions = 2;
|
const maxMentions = 2;
|
||||||
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
let mentions = ev.Thread?.PubKeys?.map(a => [a, users ? users[a] : null])?.map(a => (a[1]?.name?.length ?? 0) > 0 ? a[1].name : hexToBech32("npub", a[0]).substring(0, 12))
|
let mentions: string[] = [];
|
||||||
.sort((a, b) => a.startsWith("npub") ? 1 : -1);
|
for (let pk of ev.Thread?.PubKeys) {
|
||||||
|
let u = users?.get(pk);
|
||||||
|
if (u) {
|
||||||
|
mentions.push(u.name ?? hexToBech32("npub", pk).substring(0, 12));
|
||||||
|
} else {
|
||||||
|
mentions.push(hexToBech32("npub", pk).substring(0, 12));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mentions.sort((a, b) => a.startsWith("npub") ? 1 : -1);
|
||||||
let othersLength = mentions.length - maxMentions
|
let othersLength = mentions.length - maxMentions
|
||||||
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
|
let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", ");
|
||||||
return (
|
return (
|
||||||
<div className="reply">
|
<div className="reply">
|
||||||
➡️ {(pubMentions?.length ?? 0) > 0 ? pubMentions : hexToBech32("note", replyId)?.substring(0, 12)}
|
➡️ {(pubMentions?.length ?? 0) > 0 ? pubMentions : replyId ? hexToBech32("note", replyId)?.substring(0, 12) : ""}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -71,10 +94,10 @@ export default function Note(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`note ${hightlight ? "active" : ""} ${isThread ? "thread" : ""}`}>
|
<div className={`note ${highlight ? "active" : ""} ${isThread ? "thread" : ""}`}>
|
||||||
{options.showHeader ?
|
{options.showHeader ?
|
||||||
<div className="header flex">
|
<div className="header flex">
|
||||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag()} />
|
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||||
{options.showTime ?
|
{options.showTime ?
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<NoteTime from={ev.CreatedAt * 1000} />
|
<NoteTime from={ev.CreatedAt * 1000} />
|
@ -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 { NoteCreator } from "./NoteCreator";
|
||||||
import LNURLTip from "./LNURLTip";
|
import LNURLTip from "./LNURLTip";
|
||||||
import useProfile from "../feed/ProfileFeed";
|
import useProfile from "../feed/ProfileFeed";
|
||||||
|
import { default as NEvent } from "../nostr/Event";
|
||||||
|
import { RootState } from "../state/Store";
|
||||||
|
import { TaggedRawEvent } from "../nostr";
|
||||||
|
|
||||||
export default function NoteFooter(props) {
|
export interface NoteFooterProps {
|
||||||
|
reactions: TaggedRawEvent[],
|
||||||
|
ev: NEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NoteFooter(props: NoteFooterProps) {
|
||||||
const reactions = props.reactions;
|
const reactions = props.reactions;
|
||||||
const ev = props.ev;
|
const ev = props.ev;
|
||||||
|
|
||||||
const login = useSelector(s => s.login.publicKey);
|
const login = useSelector<RootState, string | undefined>(s => s.login.publicKey);
|
||||||
const author = useProfile(ev.RootPubKey);
|
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const [reply, setReply] = useState(false);
|
const [reply, setReply] = useState(false);
|
||||||
const [tip, setTip] = useState(false);
|
const [tip, setTip] = useState(false);
|
||||||
const isMine = ev.RootPubKey === login;
|
const isMine = ev.RootPubKey === login;
|
||||||
|
|
||||||
const groupReactions = useMemo(() => {
|
const groupReactions = useMemo(() => {
|
||||||
return reactions?.reduce((acc, { Content }) => {
|
return reactions?.reduce((acc, { content }) => {
|
||||||
let r = normalizeReaction(Content ?? "");
|
let r = normalizeReaction(content ?? "");
|
||||||
const amount = acc[r] || 0
|
const amount = acc[r] || 0
|
||||||
return { ...acc, [r]: amount + 1 }
|
return { ...acc, [r]: amount + 1 }
|
||||||
}, {
|
}, {
|
||||||
@ -31,11 +39,11 @@ export default function NoteFooter(props) {
|
|||||||
});
|
});
|
||||||
}, [reactions]);
|
}, [reactions]);
|
||||||
|
|
||||||
function hasReacted(emoji) {
|
function hasReacted(emoji: string) {
|
||||||
return reactions?.find(({ PubKey, Content }) => Content === emoji && PubKey === login)
|
return reactions?.some(({ pubkey, content }) => content === emoji && pubkey === login)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function react(content) {
|
async function react(content: string) {
|
||||||
let evLike = await publisher.react(ev, content);
|
let evLike = await publisher.react(ev, content);
|
||||||
publisher.broadcast(evLike);
|
publisher.broadcast(evLike);
|
||||||
}
|
}
|
||||||
@ -66,7 +74,7 @@ export default function NoteFooter(props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reactionIcon(content, reacted) {
|
function reactionIcon(content: string, reacted: boolean) {
|
||||||
switch (content) {
|
switch (content) {
|
||||||
case Reaction.Positive: {
|
case Reaction.Positive: {
|
||||||
return <FontAwesomeIcon color={reacted ? "red" : "currentColor"} icon={faHeart} />;
|
return <FontAwesomeIcon color={reacted ? "red" : "currentColor"} icon={faHeart} />;
|
||||||
@ -108,10 +116,10 @@ export default function NoteFooter(props) {
|
|||||||
<NoteCreator
|
<NoteCreator
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
replyTo={ev}
|
replyTo={ev}
|
||||||
onSend={(e) => setReply(false)}
|
onSend={() => setReply(false)}
|
||||||
show={reply}
|
show={reply}
|
||||||
/>
|
/>
|
||||||
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={(e) => setTip(false)} show={tip} />
|
<LNURLTip svc={author?.lud16 || author?.lud06 || ""} onClose={() => setTip(false)} show={tip} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import "./Note.css";
|
import "./Note.css";
|
||||||
import ProfileImage from "./ProfileImage";
|
import ProfileImage from "./ProfileImage";
|
||||||
|
|
||||||
export default function NoteGhost(props) {
|
export default function NoteGhost(props: any) {
|
||||||
return (
|
return (
|
||||||
<div className="note">
|
<div className="note">
|
||||||
<div className="header">
|
<div className="header">
|
@ -2,14 +2,20 @@ import "./NoteReaction.css";
|
|||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import Note from "./Note";
|
import Note from "./Note";
|
||||||
import ProfileImage from "./ProfileImage";
|
import ProfileImage from "./ProfileImage";
|
||||||
import Event from "../nostr/Event";
|
import { default as NEvent } from "../nostr/Event";
|
||||||
import { eventLink, hexToBech32 } from "../Util";
|
import { eventLink, hexToBech32 } from "../Util";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import NoteTime from "./NoteTime";
|
import NoteTime from "./NoteTime";
|
||||||
|
import { RawEvent, TaggedRawEvent } from "../nostr";
|
||||||
|
|
||||||
export default function NoteReaction(props) {
|
export interface NoteReactionProps {
|
||||||
const ev = props["data-ev"] || new Event(props.data);
|
data?: TaggedRawEvent,
|
||||||
|
["data-ev"]?: NEvent,
|
||||||
|
root?: TaggedRawEvent
|
||||||
|
}
|
||||||
|
export default function NoteReaction(props: NoteReactionProps) {
|
||||||
|
const ev = props["data-ev"] || new NEvent(props.data);
|
||||||
|
|
||||||
const refEvent = useMemo(() => {
|
const refEvent = useMemo(() => {
|
||||||
if (ev) {
|
if (ev) {
|
||||||
@ -25,7 +31,7 @@ export default function NoteReaction(props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapReaction(c) {
|
function mapReaction(c: string) {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case "+": return "❤️";
|
case "+": return "❤️";
|
||||||
case "-": return "👎";
|
case "-": return "👎";
|
||||||
@ -51,8 +57,8 @@ export default function NoteReaction(props) {
|
|||||||
function extractRoot() {
|
function extractRoot() {
|
||||||
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0) {
|
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0) {
|
||||||
try {
|
try {
|
||||||
let r = JSON.parse(ev.Content);
|
let r: RawEvent = JSON.parse(ev.Content);
|
||||||
return r;
|
return r as TaggedRawEvent;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Could not load reposted content", e);
|
console.error("Could not load reposted content", e);
|
||||||
}
|
}
|
||||||
@ -74,7 +80,7 @@ export default function NoteReaction(props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{root ? <Note data={root} options={opt} /> : null}
|
{root ? <Note data={root} options={opt} reactions={[]} deletion={[]} /> : null}
|
||||||
{!root && refEvent ? <p><Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link></p> : null}
|
{!root && refEvent ? <p><Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link></p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
@ -4,8 +4,14 @@ const MinuteInMs = 1_000 * 60;
|
|||||||
const HourInMs = MinuteInMs * 60;
|
const HourInMs = MinuteInMs * 60;
|
||||||
const DayInMs = HourInMs * 24;
|
const DayInMs = HourInMs * 24;
|
||||||
|
|
||||||
export default function NoteTime({ from, fallback = '' }) {
|
export interface NoteTimeProps {
|
||||||
const [time, setTime] = useState("");
|
from: number,
|
||||||
|
fallback?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NoteTime(props: NoteTimeProps) {
|
||||||
|
const [time, setTime] = useState<string>();
|
||||||
|
const { from, fallback } = props;
|
||||||
|
|
||||||
function calcTime() {
|
function calcTime() {
|
||||||
let fromDate = new Date(from);
|
let fromDate = new Date(from);
|
||||||
@ -18,7 +24,7 @@ export default function NoteTime({ from, fallback = '' }) {
|
|||||||
} else if (absAgo < MinuteInMs) {
|
} else if (absAgo < MinuteInMs) {
|
||||||
return fallback
|
return fallback
|
||||||
} else {
|
} else {
|
||||||
let mins = parseInt(absAgo / MinuteInMs);
|
let mins = Math.floor(absAgo / MinuteInMs);
|
||||||
let minutes = mins === 1 ? 'min' : 'mins'
|
let minutes = mins === 1 ? 'min' : 'mins'
|
||||||
return `${mins} ${minutes} ago`;
|
return `${mins} ${minutes} ago`;
|
||||||
}
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import "./ProfileImage.css";
|
import "./ProfileImage.css";
|
||||||
|
// @ts-ignore
|
||||||
import Nostrich from "../nostrich.jpg";
|
import Nostrich from "../nostrich.jpg";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@ -7,25 +8,34 @@ import useProfile from "../feed/ProfileFeed";
|
|||||||
import { hexToBech32, profileLink } from "../Util";
|
import { hexToBech32, profileLink } from "../Util";
|
||||||
import LazyImage from "./LazyImage";
|
import LazyImage from "./LazyImage";
|
||||||
import Nip05 from "./Nip05";
|
import Nip05 from "./Nip05";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
|
|
||||||
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }) {
|
export interface ProfileImageProps {
|
||||||
|
pubkey: HexKey,
|
||||||
|
subHeader?: JSX.Element,
|
||||||
|
showUsername?: boolean,
|
||||||
|
className?: string,
|
||||||
|
link?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useProfile(pubkey);
|
const user = useProfile(pubkey)?.get(pubkey);
|
||||||
|
|
||||||
const hasImage = (user?.picture?.length ?? 0) > 0;
|
const hasImage = (user?.picture?.length ?? 0) > 0;
|
||||||
const name = useMemo(() => {
|
const name = useMemo(() => {
|
||||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
||||||
if (user?.display_name?.length > 0) {
|
if ((user?.display_name?.length ?? 0) > 0) {
|
||||||
name = user.display_name;
|
name = user!.display_name!;
|
||||||
} else if (user?.name?.length > 0) {
|
} else if ((user?.name?.length ?? 0) > 0) {
|
||||||
name = user.name;
|
name = user!.name!;
|
||||||
}
|
}
|
||||||
return name;
|
return name;
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`pfp ${className}`}>
|
<div className={`pfp${className ? ` ${className}` : ""}`}>
|
||||||
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
<LazyImage src={hasImage ? user!.picture : Nostrich} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
||||||
{showUsername && (<div className="f-grow">
|
{showUsername && (<div className="f-grow">
|
||||||
<Link key={pubkey} to={link ?? profileLink(pubkey)}>
|
<Link key={pubkey} to={link ?? profileLink(pubkey)}>
|
||||||
<div className="profile-name">
|
<div className="profile-name">
|
@ -2,10 +2,17 @@ import "./ProfilePreview.css";
|
|||||||
import ProfileImage from "./ProfileImage";
|
import ProfileImage from "./ProfileImage";
|
||||||
import FollowButton from "./FollowButton";
|
import FollowButton from "./FollowButton";
|
||||||
import useProfile from "../feed/ProfileFeed";
|
import useProfile from "../feed/ProfileFeed";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
|
|
||||||
export default function ProfilePreview(props) {
|
export interface ProfilePreviewProps {
|
||||||
|
pubkey: HexKey,
|
||||||
|
options?: {
|
||||||
|
about?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||||
const pubkey = props.pubkey;
|
const pubkey = props.pubkey;
|
||||||
const user = useProfile(pubkey);
|
const user = useProfile(pubkey)?.get(pubkey);
|
||||||
const options = {
|
const options = {
|
||||||
about: true,
|
about: true,
|
||||||
...props.options
|
...props.options
|
||||||
@ -16,7 +23,7 @@ export default function ProfilePreview(props) {
|
|||||||
<ProfileImage pubkey={pubkey} subHeader=
|
<ProfileImage pubkey={pubkey} subHeader=
|
||||||
{options.about ? <div className="f-ellipsis about">
|
{options.about ? <div className="f-ellipsis about">
|
||||||
{user?.about}
|
{user?.about}
|
||||||
</div> : null} />
|
</div> : undefined} />
|
||||||
<FollowButton pubkey={pubkey} className="ml5" />
|
<FollowButton pubkey={pubkey} className="ml5" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
@ -1,11 +1,19 @@
|
|||||||
import QRCodeStyling from "qr-code-styling";
|
import QRCodeStyling from "qr-code-styling";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export default function QrCode(props) {
|
export interface QrCodeProps {
|
||||||
const qrRef = useRef();
|
data?: string,
|
||||||
|
link?: string,
|
||||||
|
avatar?: string,
|
||||||
|
height?: number,
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QrCode(props: QrCodeProps) {
|
||||||
|
const qrRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.data?.length > 0) {
|
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
|
||||||
let qr = new QRCodeStyling({
|
let qr = new QRCodeStyling({
|
||||||
width: props.width || 256,
|
width: props.width || 256,
|
||||||
height: props.height || 256,
|
height: props.height || 256,
|
||||||
@ -28,11 +36,11 @@ export default function QrCode(props) {
|
|||||||
if (props.link) {
|
if (props.link) {
|
||||||
qrRef.current.onclick = function (e) {
|
qrRef.current.onclick = function (e) {
|
||||||
let elm = document.createElement("a");
|
let elm = document.createElement("a");
|
||||||
elm.href = props.link;
|
elm.href = props.link!;
|
||||||
elm.click();
|
elm.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (qrRef.current) {
|
||||||
qrRef.current.innerHTML = "";
|
qrRef.current.innerHTML = "";
|
||||||
}
|
}
|
||||||
}, [props.data, props.link]);
|
}, [props.data, props.link]);
|
@ -6,23 +6,33 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { removeRelay, setRelays } from "../state/Login";
|
import { removeRelay, setRelays } from "../state/Login";
|
||||||
|
import { RootState } from "../state/Store";
|
||||||
|
import { RelaySettings } from "../nostr/Connection";
|
||||||
|
|
||||||
|
export interface RelayProps {
|
||||||
|
addr: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function Relay(props) {
|
export default function Relay(props: RelayProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const relaySettings = useSelector(s => s.login.relays[props.addr]);
|
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
|
||||||
|
const relaySettings = allRelaySettings[props.addr];
|
||||||
const state = useRelayState(props.addr);
|
const state = useRelayState(props.addr);
|
||||||
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
|
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
|
||||||
const [showExtra, setShowExtra] = useState(false);
|
const [showExtra, setShowExtra] = useState(false);
|
||||||
|
|
||||||
function configure(o) {
|
function configure(o: RelaySettings) {
|
||||||
dispatch(setRelays({
|
dispatch(setRelays({
|
||||||
|
relays: {
|
||||||
|
...allRelaySettings,
|
||||||
[props.addr]: o
|
[props.addr]: o
|
||||||
|
},
|
||||||
|
createdAt: Math.floor(new Date().getTime() / 1000)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let latency = parseInt(state?.avgLatency ?? 0);
|
let latency = Math.floor(state?.avgLatency ?? 0);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`relay w-max`}>
|
<div className={`relay w-max`}>
|
@ -10,8 +10,10 @@ import Hashtag from "./Hashtag";
|
|||||||
|
|
||||||
import './Text.css'
|
import './Text.css'
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import Tag from "../nostr/Tag";
|
||||||
|
import { MetadataCache } from "../db/User";
|
||||||
|
|
||||||
function transformHttpLink(a) {
|
function transformHttpLink(a: string) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(a);
|
const url = new URL(a);
|
||||||
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
|
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
|
||||||
@ -32,10 +34,10 @@ function transformHttpLink(a) {
|
|||||||
case "mkv":
|
case "mkv":
|
||||||
case "avi":
|
case "avi":
|
||||||
case "m4v": {
|
case "m4v": {
|
||||||
return <video key={url} src={url} controls />
|
return <video key={url.toString()} src={url.toString()} controls />
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return <a key={url} href={url} onClick={(e) => e.stopPropagation()}>{url.toString()}</a>
|
return <a key={url.toString()} href={url.toString()} onClick={(e) => e.stopPropagation()}>{url.toString()}</a>
|
||||||
}
|
}
|
||||||
} else if (tweetId) {
|
} else if (tweetId) {
|
||||||
return (
|
return (
|
||||||
@ -54,7 +56,7 @@ function transformHttpLink(a) {
|
|||||||
key={youtubeId}
|
key={youtubeId}
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
allowFullScreen=""
|
allowFullScreen={true}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
</>
|
</>
|
||||||
@ -67,7 +69,7 @@ function transformHttpLink(a) {
|
|||||||
return <a href={a} onClick={(e) => e.stopPropagation()}>{a}</a>
|
return <a href={a} onClick={(e) => e.stopPropagation()}>{a}</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLinks(fragments) {
|
function extractLinks(fragments: Fragment[]) {
|
||||||
return fragments.map(f => {
|
return fragments.map(f => {
|
||||||
if (typeof f === "string") {
|
if (typeof f === "string") {
|
||||||
return f.split(UrlRegex).map(a => {
|
return f.split(UrlRegex).map(a => {
|
||||||
@ -81,7 +83,7 @@ function extractLinks(fragments) {
|
|||||||
}).flat();
|
}).flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMentions(fragments, tags, users) {
|
function extractMentions(fragments: Fragment[], tags: Tag[], users: Map<string, MetadataCache>) {
|
||||||
return fragments.map(f => {
|
return fragments.map(f => {
|
||||||
if (typeof f === "string") {
|
if (typeof f === "string") {
|
||||||
return f.split(MentionRegex).map((match) => {
|
return f.split(MentionRegex).map((match) => {
|
||||||
@ -92,12 +94,12 @@ function extractMentions(fragments, tags, users) {
|
|||||||
if (ref) {
|
if (ref) {
|
||||||
switch (ref.Key) {
|
switch (ref.Key) {
|
||||||
case "p": {
|
case "p": {
|
||||||
let pUser = users[ref.PubKey]?.name ?? hexToBech32("npub", ref.PubKey).substring(0, 12);
|
let pUser = users.get(ref.PubKey!)?.name ?? hexToBech32("npub", ref.PubKey!).substring(0, 12);
|
||||||
return <Link key={ref.PubKey} to={profileLink(ref.PubKey)} onClick={(e) => e.stopPropagation()}>@{pUser}</Link>;
|
return <Link key={ref.PubKey} to={profileLink(ref.PubKey!)} onClick={(e) => e.stopPropagation()}>@{pUser}</Link>;
|
||||||
}
|
}
|
||||||
case "e": {
|
case "e": {
|
||||||
let eText = hexToBech32("note", ref.Event).substring(0, 12);
|
let eText = hexToBech32("note", ref.Event!).substring(0, 12);
|
||||||
return <Link key={ref.Event} to={eventLink(ref.Event)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>;
|
return <Link key={ref.Event} to={eventLink(ref.Event!)} onClick={(e) => e.stopPropagation()}>#{eText}</Link>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -111,7 +113,7 @@ function extractMentions(fragments, tags, users) {
|
|||||||
}).flat();
|
}).flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractInvoices(fragments) {
|
function extractInvoices(fragments: Fragment[]) {
|
||||||
return fragments.map(f => {
|
return fragments.map(f => {
|
||||||
if (typeof f === "string") {
|
if (typeof f === "string") {
|
||||||
return f.split(InvoiceRegex).map(i => {
|
return f.split(InvoiceRegex).map(i => {
|
||||||
@ -126,7 +128,7 @@ function extractInvoices(fragments) {
|
|||||||
}).flat();
|
}).flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractHashtags(fragments) {
|
function extractHashtags(fragments: Fragment[]) {
|
||||||
return fragments.map(f => {
|
return fragments.map(f => {
|
||||||
if (typeof f === "string") {
|
if (typeof f === "string") {
|
||||||
return f.split(HashtagRegex).map(i => {
|
return f.split(HashtagRegex).map(i => {
|
||||||
@ -141,12 +143,12 @@ function extractHashtags(fragments) {
|
|||||||
}).flat();
|
}).flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformLi({ body, tags, users }) {
|
function transformLi({ body, tags, users }: TextFragment) {
|
||||||
let fragments = transformText({ body, tags, users })
|
let fragments = transformText({ body, tags, users })
|
||||||
return <li>{fragments}</li>
|
return <li>{fragments}</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformParagraph({ body, tags, users }) {
|
function transformParagraph({ body, tags, users }: TextFragment) {
|
||||||
const fragments = transformText({ body, tags, users })
|
const fragments = transformText({ body, tags, users })
|
||||||
if (fragments.every(f => typeof f === 'string')) {
|
if (fragments.every(f => typeof f === 'string')) {
|
||||||
return <p>{fragments}</p>
|
return <p>{fragments}</p>
|
||||||
@ -154,7 +156,7 @@ function transformParagraph({ body, tags, users }) {
|
|||||||
return <>{fragments}</>
|
return <>{fragments}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformText({ body, tags, users }) {
|
function transformText({ body, tags, users }: TextFragment) {
|
||||||
let fragments = extractMentions(body, tags, users);
|
let fragments = extractMentions(body, tags, users);
|
||||||
fragments = extractLinks(fragments);
|
fragments = extractLinks(fragments);
|
||||||
fragments = extractInvoices(fragments);
|
fragments = extractInvoices(fragments);
|
||||||
@ -162,12 +164,26 @@ function transformText({ body, tags, users }) {
|
|||||||
return fragments;
|
return fragments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Text({ content, tags, users }) {
|
export type Fragment = string | JSX.Element;
|
||||||
|
|
||||||
|
export interface TextFragment {
|
||||||
|
body: Fragment[],
|
||||||
|
tags: Tag[],
|
||||||
|
users: Map<string, MetadataCache>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextProps {
|
||||||
|
content: string,
|
||||||
|
tags: Tag[],
|
||||||
|
users: Map<string, MetadataCache>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Text({ content, tags, users }: TextProps) {
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
p: (props) => transformParagraph({ body: props.children, tags, users }),
|
p: (x: any) => transformParagraph({ body: x.children, tags, users }),
|
||||||
a: (props) => transformHttpLink(props.href),
|
a: (x: any) => transformHttpLink(x.href),
|
||||||
li: (props) => transformLi({ body: props.children, tags, users }),
|
li: (x: any) => transformLi({ body: x.children, tags, users }),
|
||||||
};
|
};
|
||||||
}, [content]);
|
}, [content]);
|
||||||
return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown>
|
return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown>
|
@ -1,14 +1,13 @@
|
|||||||
import { useLiveQuery } from "dexie-react-hooks";
|
|
||||||
|
|
||||||
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
import Nip05 from "./Nip05";
|
|
||||||
import "@webscopeio/react-textarea-autocomplete/style.css";
|
|
||||||
import "./Textarea.css";
|
import "./Textarea.css";
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
import Nostrich from "../nostrich.jpg";
|
import Nostrich from "../nostrich.jpg";
|
||||||
|
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
||||||
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
import "@webscopeio/react-textarea-autocomplete/style.css";
|
||||||
|
|
||||||
|
import Nip05 from "./Nip05";
|
||||||
import { hexToBech32 } from "../Util";
|
import { hexToBech32 } from "../Util";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { MetadataCache } from "../db/User";
|
import { MetadataCache } from "../db/User";
|
||||||
|
@ -1,29 +1,32 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 EventKind from "../nostr/EventKind";
|
||||||
import { eventLink } from "../Util";
|
import { eventLink } from "../Util";
|
||||||
import Note from "./Note";
|
import Note from "./Note";
|
||||||
import NoteGhost from "./NoteGhost";
|
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;
|
const thisEvent = props.this;
|
||||||
|
const notes = props.notes?.map(a => new NEvent(a));
|
||||||
/** @type {Array<Event>} */
|
|
||||||
const notes = props.notes?.map(a => new Event(a));
|
|
||||||
|
|
||||||
// root note has no thread info
|
// 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(() => {
|
const chains = useMemo(() => {
|
||||||
let chains = new Map();
|
let chains = new Map<u256, NEvent[]>();
|
||||||
notes.filter(a => a.Kind === EventKind.TextNote).sort((a, b) => b.CreatedAt - a.CreatedAt).forEach((v) => {
|
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;
|
let replyTo = v.Thread?.ReplyTo?.Event ?? v.Thread?.Root?.Event;
|
||||||
if (replyTo) {
|
if (replyTo) {
|
||||||
if (!chains.has(replyTo)) {
|
if (!chains.has(replyTo)) {
|
||||||
chains.set(replyTo, [v]);
|
chains.set(replyTo, [v]);
|
||||||
} else {
|
} else {
|
||||||
chains.get(replyTo).push(v);
|
chains.get(replyTo)!.push(v);
|
||||||
}
|
}
|
||||||
} else if (v.Tags.length > 0) {
|
} else if (v.Tags.length > 0) {
|
||||||
console.log("Not replying to anything: ", v);
|
console.log("Not replying to anything: ", v);
|
||||||
@ -34,15 +37,15 @@ export default function Thread(props) {
|
|||||||
}, [notes]);
|
}, [notes]);
|
||||||
|
|
||||||
const brokenChains = useMemo(() => {
|
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]);
|
}, [chains]);
|
||||||
|
|
||||||
const mentionsRoot = useMemo(() => {
|
const mentionsRoot = useMemo(() => {
|
||||||
return notes.filter(a => a.Kind === EventKind.TextNote && a.Thread)
|
return notes?.filter(a => a.Kind === EventKind.TextNote && a.Thread)
|
||||||
}, [chains]);
|
}, [chains]);
|
||||||
|
|
||||||
function reactions(id, kind = EventKind.Reaction) {
|
function reactions(id: u256, kind = EventKind.Reaction) {
|
||||||
return notes?.filter(a => a.Kind === kind && a.Tags.find(a => a.Key === "e" && a.Event === id));
|
return (notes?.filter(a => a.Kind === kind && a.Tags.find(a => a.Key === "e" && a.Event === id)) || []).map(a => a.Original!);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoot() {
|
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 />
|
return <Note data-ev={root} reactions={reactions(root.Id)} deletion={reactions(root.Id, EventKind.Deletion)} isThread />
|
||||||
} else {
|
} else {
|
||||||
return <NoteGhost>
|
return <NoteGhost>
|
||||||
Loading thread root.. ({notes.length} notes loaded)
|
Loading thread root.. ({notes?.length} notes loaded)
|
||||||
</NoteGhost>
|
</NoteGhost>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChain(from) {
|
function renderChain(from: u256) {
|
||||||
if (from && chains) {
|
if (from && chains) {
|
||||||
let replies = chains.get(from);
|
let replies = chains.get(from);
|
||||||
if (replies) {
|
if (replies) {
|
||||||
@ -64,7 +67,11 @@ export default function Thread(props) {
|
|||||||
{replies.map(a => {
|
{replies.map(a => {
|
||||||
return (
|
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)}
|
{renderChain(a.Id)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
@ -1,16 +1,22 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import useTimelineFeed from "../feed/TimelineFeed";
|
import useTimelineFeed from "../feed/TimelineFeed";
|
||||||
|
import { HexKey, TaggedRawEvent, u256 } from "../nostr";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import Note from "./Note";
|
import Note from "./Note";
|
||||||
import NoteReaction from "./NoteReaction";
|
import NoteReaction from "./NoteReaction";
|
||||||
|
|
||||||
|
export interface TimelineProps {
|
||||||
|
global: boolean,
|
||||||
|
pubkeys: HexKey[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of notes by pubkeys
|
* A list of notes by pubkeys
|
||||||
*/
|
*/
|
||||||
export default function Timeline({ global, pubkeys }) {
|
export default function Timeline({ global, pubkeys }: TimelineProps) {
|
||||||
const feed = useTimelineFeed(pubkeys, global);
|
const feed = useTimelineFeed(pubkeys, global);
|
||||||
|
|
||||||
function reaction(id, kind = EventKind.Reaction) {
|
function reaction(id: u256, kind = EventKind.Reaction) {
|
||||||
return feed?.others?.filter(a => a.kind === kind && a.tags.some(b => b[0] === "e" && b[1] === id));
|
return feed?.others?.filter(a => a.kind === kind && a.tags.some(b => b[0] === "e" && b[1] === id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,7 +24,7 @@ export default function Timeline({ global, pubkeys }) {
|
|||||||
return feed.main?.sort((a, b) => b.created_at - a.created_at);
|
return feed.main?.sort((a, b) => b.created_at - a.created_at);
|
||||||
}, [feed]);
|
}, [feed]);
|
||||||
|
|
||||||
function eventElement(e) {
|
function eventElement(e: TaggedRawEvent) {
|
||||||
switch (e.kind) {
|
switch (e.kind) {
|
||||||
case EventKind.TextNote: {
|
case EventKind.TextNote: {
|
||||||
return <Note key={e.id} data={e} reactions={reaction(e.id)} deletion={reaction(e.id, EventKind.Deletion)} />
|
return <Note key={e.id} data={e} reactions={reaction(e.id)} deletion={reaction(e.id, EventKind.Deletion)} />
|
||||||
@ -30,5 +36,5 @@ export default function Timeline({ global, pubkeys }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mainFeed.map(eventElement);
|
return <>{mainFeed.map(eventElement)}</>;
|
||||||
}
|
}
|
@ -139,7 +139,7 @@ export default function useEventPublisher() {
|
|||||||
return await signEvent(ev);
|
return await signEvent(ev);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addFollow: async (pkAdd: HexKey) => {
|
addFollow: async (pkAdd: HexKey | HexKey[]) => {
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
let ev = NEvent.ForPubKey(pubKey);
|
let ev = NEvent.ForPubKey(pubKey);
|
||||||
ev.Kind = EventKind.ContactList;
|
ev.Kind = EventKind.ContactList;
|
||||||
|
@ -1,19 +1,26 @@
|
|||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
|
import { MetadataCache } from "../db/User";
|
||||||
import { HexKey } from "../nostr";
|
import { HexKey } from "../nostr";
|
||||||
import { System } from "../nostr/System";
|
import { System } from "../nostr/System";
|
||||||
|
|
||||||
export default function useProfile(pubKey: HexKey | Array<HexKey>) {
|
export default function useProfile(pubKey: HexKey | Array<HexKey> | undefined): Map<HexKey, MetadataCache> | undefined {
|
||||||
const user = useLiveQuery(async () => {
|
const user = useLiveQuery(async () => {
|
||||||
|
let userList = new Map<HexKey, MetadataCache>();
|
||||||
if (pubKey) {
|
if (pubKey) {
|
||||||
if (Array.isArray(pubKey)) {
|
if (Array.isArray(pubKey)) {
|
||||||
let ret = await db.users.bulkGet(pubKey);
|
let ret = await db.users.bulkGet(pubKey);
|
||||||
return ret.filter(a => a !== undefined).map(a => a!);
|
let filtered = ret.filter(a => a !== undefined).map(a => a!);
|
||||||
|
return new Map(filtered.map(a => [a.pubkey, a]))
|
||||||
} else {
|
} else {
|
||||||
return await db.users.get(pubKey);
|
let ret = await db.users.get(pubKey);
|
||||||
|
if (ret) {
|
||||||
|
userList.set(ret.pubkey, ret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return userList;
|
||||||
}, [pubKey]);
|
}, [pubKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { System } from "../nostr/System";
|
import { System } from "../nostr/System";
|
||||||
import { CustomHook } from "../nostr/Connection";
|
import { CustomHook, StateSnapshot } from "../nostr/Connection";
|
||||||
|
|
||||||
const noop = (f: CustomHook) => { return () => { }; };
|
const noop = (f: CustomHook) => { return () => { }; };
|
||||||
const noopState = () => { };
|
const noopState = (): StateSnapshot | undefined => {
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export default function useRelayState(addr: string) {
|
export default function useRelayState(addr: string) {
|
||||||
let c = System.Sockets.get(addr);
|
let c = System.Sockets.get(addr);
|
||||||
return useSyncExternalStore(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState);
|
return useSyncExternalStore<StateSnapshot | undefined>(c?.StatusHook.bind(c) ?? noop, c?.GetState.bind(c) ?? noopState);
|
||||||
}
|
}
|
@ -17,7 +17,7 @@ export default function useTimelineFeed(pubKeys: HexKey | Array<HexKey>, global:
|
|||||||
|
|
||||||
let sub = new Subscriptions();
|
let sub = new Subscriptions();
|
||||||
sub.Id = `timeline:${subTab}`;
|
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.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
|
||||||
sub.Limit = 20;
|
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() {
|
get RootPubKey() {
|
||||||
let delegation = this.Tags.find(a => a.Key === "delegation");
|
let delegation = this.Tags.find(a => a.Key === "delegation");
|
||||||
if (delegation) {
|
if (delegation?.PubKey) {
|
||||||
return delegation.PubKey;
|
return delegation.PubKey;
|
||||||
}
|
}
|
||||||
return this.PubKey;
|
return this.PubKey;
|
||||||
|
@ -15,42 +15,42 @@ export class Subscriptions {
|
|||||||
/**
|
/**
|
||||||
* a list of event ids or prefixes
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* maximum number of events to be returned in the initial query
|
||||||
*/
|
*/
|
||||||
Limit: number | null;
|
Limit?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler function for this event
|
* Handler function for this event
|
||||||
@ -79,14 +79,14 @@ export class Subscriptions {
|
|||||||
|
|
||||||
constructor(sub?: RawReqFilter) {
|
constructor(sub?: RawReqFilter) {
|
||||||
this.Id = uuid();
|
this.Id = uuid();
|
||||||
this.Ids = sub?.ids ? new Set(sub.ids) : null;
|
this.Ids = sub?.ids ? new Set(sub.ids) : undefined;
|
||||||
this.Authors = sub?.authors ? new Set(sub.authors) : null;
|
this.Authors = sub?.authors ? new Set(sub.authors) : undefined;
|
||||||
this.Kinds = sub?.kinds ? new Set(sub.kinds) : null;
|
this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined;
|
||||||
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : null;
|
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
|
||||||
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : null;
|
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
|
||||||
this.Since = sub?.since ?? null;
|
this.Since = sub?.since ?? undefined;
|
||||||
this.Until = sub?.until ?? null;
|
this.Until = sub?.until ?? undefined;
|
||||||
this.Limit = sub?.limit ?? null;
|
this.Limit = sub?.limit ?? undefined;
|
||||||
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
|
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
|
||||||
this.OnEnd = (c) => { };
|
this.OnEnd = (c) => { };
|
||||||
this.OrSubs = [];
|
this.OrSubs = [];
|
||||||
|
@ -4,11 +4,8 @@ import { useSelector } from "react-redux";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import ProfileImage from "../element/ProfileImage";
|
import ProfileImage from "../element/ProfileImage";
|
||||||
// @ts-ignore
|
|
||||||
import { bech32ToHex } from "../Util";
|
import { bech32ToHex } from "../Util";
|
||||||
// @ts-ignore
|
|
||||||
import useEventPublisher from "../feed/EventPublisher";
|
import useEventPublisher from "../feed/EventPublisher";
|
||||||
|
|
||||||
import DM from "../element/DM";
|
import DM from "../element/DM";
|
||||||
|
@ -6,8 +6,7 @@ import { parseId } from "../Util";
|
|||||||
|
|
||||||
export default function EventPage() {
|
export default function EventPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = parseId(params.id);
|
const id = parseId(params.id!);
|
||||||
|
|
||||||
const thread = useThreadFeed(id);
|
const thread = useThreadFeed(id);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
@ -9,15 +9,18 @@ import { System } from "../nostr/System"
|
|||||||
import ProfileImage from "../element/ProfileImage";
|
import ProfileImage from "../element/ProfileImage";
|
||||||
import { init } from "../state/Login";
|
import { init } from "../state/Login";
|
||||||
import useLoginFeed from "../feed/LoginFeed";
|
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 dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isInit = useSelector(s => s.login.loggedOut);
|
const isInit = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut);
|
||||||
const key = useSelector(s => s.login.publicKey);
|
const key = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||||
const relays = useSelector(s => s.login.relays);
|
const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
|
||||||
const notifications = useSelector(s => s.login.notifications);
|
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
|
||||||
const readNotifications = useSelector(s => s.login.readNotifications);
|
const readNotifications = useSelector<RootState, number>(s => s.login.readNotifications);
|
||||||
useLoginFeed();
|
useLoginFeed();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -37,7 +40,7 @@ export default function Layout(props) {
|
|||||||
dispatch(init());
|
dispatch(init());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function goToNotifications(e) {
|
async function goToNotifications(e: any) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// request permissions to send notifications
|
// request permissions to send notifications
|
||||||
if ("Notification" in window && Notification.permission !== "granted") {
|
if ("Notification" in window && Notification.permission !== "granted") {
|
||||||
@ -64,7 +67,7 @@ export default function Layout(props) {
|
|||||||
{unreadNotifications > 0 && (<span className="unread-count">
|
{unreadNotifications > 0 && (<span className="unread-count">
|
||||||
{unreadNotifications > 100 ? ">99" : unreadNotifications}
|
{unreadNotifications > 100 ? ">99" : unreadNotifications}
|
||||||
</span>)}
|
</span>)}
|
||||||
<ProfileImage pubkey={key} showUsername={false} />
|
<ProfileImage pubkey={key || ""} showUsername={false} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -84,7 +87,7 @@ export default function Layout(props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Outlet/>
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -6,11 +6,13 @@ import * as secp from '@noble/secp256k1';
|
|||||||
import { setPrivateKey, setPublicKey } from "../state/Login";
|
import { setPrivateKey, setPublicKey } from "../state/Login";
|
||||||
import { EmailRegex } from "../Const";
|
import { EmailRegex } from "../Const";
|
||||||
import { bech32ToHex } from "../Util";
|
import { bech32ToHex } from "../Util";
|
||||||
|
import { RootState } from "../state/Store";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
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 [key, setKey] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@ -20,7 +22,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [publicKey]);
|
}, [publicKey]);
|
||||||
|
|
||||||
async function getNip05PubKey(addr) {
|
async function getNip05PubKey(addr: string) {
|
||||||
let [username, domain] = addr.split("@");
|
let [username, domain] = addr.split("@");
|
||||||
let rsp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(username)}`);
|
let rsp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(username)}`);
|
||||||
if (rsp.ok) {
|
if (rsp.ok) {
|
@ -1,8 +1,7 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
|
||||||
import { RecommendedFollows } from "../Const";
|
import { RecommendedFollows } from "../Const";
|
||||||
import ProfilePreview from "../element/ProfilePreview";
|
import ProfilePreview from "../element/ProfilePreview";
|
||||||
|
|
||||||
export default function NewUserPage(props) {
|
export default function NewUserPage() {
|
||||||
|
|
||||||
function followSomebody() {
|
function followSomebody() {
|
||||||
return (
|
return (
|
@ -3,14 +3,16 @@ import { useDispatch, useSelector } from "react-redux"
|
|||||||
import Note from "../element/Note";
|
import Note from "../element/Note";
|
||||||
import NoteReaction from "../element/NoteReaction";
|
import NoteReaction from "../element/NoteReaction";
|
||||||
import useSubscription from "../feed/Subscription";
|
import useSubscription from "../feed/Subscription";
|
||||||
|
import { TaggedRawEvent } from "../nostr";
|
||||||
import Event from "../nostr/Event";
|
import Event from "../nostr/Event";
|
||||||
import EventKind from "../nostr/EventKind";
|
import EventKind from "../nostr/EventKind";
|
||||||
import { Subscriptions } from "../nostr/Subscriptions";
|
import { Subscriptions } from "../nostr/Subscriptions";
|
||||||
import { markNotificationsRead } from "../state/Login";
|
import { markNotificationsRead } from "../state/Login";
|
||||||
|
import { RootState } from "../state/Store";
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const notifications = useSelector(s => s.login.notifications);
|
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(markNotificationsRead());
|
dispatch(markNotificationsRead());
|
||||||
@ -21,7 +23,7 @@ export default function NotificationsPage() {
|
|||||||
.map(a => {
|
.map(a => {
|
||||||
let ev = new Event(a);
|
let ev = new Event(a);
|
||||||
return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||||
})
|
}).filter(a => a !== undefined).map(a => a!);
|
||||||
}, [notifications]);
|
}, [notifications]);
|
||||||
|
|
||||||
const subEvents = useMemo(() => {
|
const subEvents = useMemo(() => {
|
||||||
@ -50,7 +52,7 @@ export default function NotificationsPage() {
|
|||||||
{sorted?.map(a => {
|
{sorted?.map(a => {
|
||||||
if (a.kind === EventKind.TextNote) {
|
if (a.kind === EventKind.TextNote) {
|
||||||
let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id));
|
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) {
|
} else if (a.kind === EventKind.Reaction) {
|
||||||
let ev = new Event(a);
|
let ev = new Event(a);
|
||||||
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
@ -1,10 +1,11 @@
|
|||||||
import "./ProfilePage.css";
|
import "./ProfilePage.css";
|
||||||
|
// @ts-ignore
|
||||||
import Nostrich from "../nostrich.jpg";
|
import Nostrich from "../nostrich.jpg";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faQrcode, faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons";
|
import { faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import useProfile from "../feed/ProfileFeed";
|
import useProfile from "../feed/ProfileFeed";
|
||||||
@ -18,25 +19,27 @@ import Copy from "../element/Copy";
|
|||||||
import ProfilePreview from "../element/ProfilePreview";
|
import ProfilePreview from "../element/ProfilePreview";
|
||||||
import FollowersList from "../element/FollowersList";
|
import FollowersList from "../element/FollowersList";
|
||||||
import FollowsList from "../element/FollowsList";
|
import FollowsList from "../element/FollowsList";
|
||||||
|
import { RootState } from "../state/Store";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
|
|
||||||
const ProfileTab = {
|
enum ProfileTab {
|
||||||
Notes: 0,
|
Notes = "Notes",
|
||||||
//Reactions: 1,
|
Reactions = "Reactions",
|
||||||
Followers: 2,
|
Followers = "Followers",
|
||||||
Follows: 3
|
Follows = "Follows"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const id = useMemo(() => parseId(params.id), [params]);
|
const id = useMemo(() => parseId(params.id!), [params]);
|
||||||
const user = useProfile(id);
|
const user = useProfile(id)?.get(id);
|
||||||
const loginPubKey = useSelector(s => s.login.publicKey);
|
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||||
const follows = useSelector(s => s.login.follows);
|
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
|
||||||
const isMe = loginPubKey === id;
|
const isMe = loginPubKey === id;
|
||||||
const [showLnQr, setShowLnQr] = useState(false);
|
const [showLnQr, setShowLnQr] = useState<boolean>(false);
|
||||||
const [tab, setTab] = useState(ProfileTab.Notes);
|
const [tab, setTab] = useState(ProfileTab.Notes);
|
||||||
const about = Text({ content: user?.about })
|
const about = Text({ content: user?.about ?? "", users: new Map(), tags: [] })
|
||||||
const avatarUrl = (user?.picture?.length ?? 0) === 0 ? Nostrich : user?.picture
|
const avatarUrl = (user?.picture?.length ?? 0) === 0 ? Nostrich : user?.picture
|
||||||
const backgroundImage = `url(${avatarUrl})`
|
const backgroundImage = `url(${avatarUrl})`
|
||||||
const domain = user?.nip05 && user.nip05.split('@')[1]
|
const domain = user?.nip05 && user.nip05.split('@')[1]
|
||||||
@ -49,7 +52,7 @@ export default function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="name">
|
<div className="name">
|
||||||
<h2>{user?.display_name || user?.name || 'Nostrich'}</h2>
|
<h2>{user?.display_name || user?.name || 'Nostrich'}</h2>
|
||||||
<Copy text={params.id} />
|
<Copy text={params.id || ""} />
|
||||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -84,7 +87,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
function tabContent() {
|
function tabContent() {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case ProfileTab.Notes: return <Timeline pubkeys={id} />;
|
case ProfileTab.Notes: return <Timeline pubkeys={[id]} global={false} />;
|
||||||
case ProfileTab.Follows: {
|
case ProfileTab.Follows: {
|
||||||
if (isMe) {
|
if (isMe) {
|
||||||
return (
|
return (
|
||||||
@ -101,13 +104,12 @@ export default function ProfilePage() {
|
|||||||
return <FollowersList pubkey={id} />
|
return <FollowersList pubkey={id} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function avatar() {
|
function avatar() {
|
||||||
return (
|
return (
|
||||||
<div className="avatar-wrapper">
|
<div className="avatar-wrapper">
|
||||||
<div style={{ '--img-url': backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}>
|
<div style={{ ['--img-url' as any]: backgroundImage }} className="avatar" data-domain={domain?.toLowerCase()}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -150,8 +152,8 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
{Object.entries(ProfileTab).map(([k, v]) => {
|
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => {
|
||||||
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={k} onClick={() => setTab(v)}>{k}</div>
|
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{tabContent()}
|
{tabContent()}
|
@ -4,7 +4,8 @@ import { Link } from "react-router-dom";
|
|||||||
import { NoteCreator } from "../element/NoteCreator";
|
import { NoteCreator } from "../element/NoteCreator";
|
||||||
import Timeline from "../element/Timeline";
|
import Timeline from "../element/Timeline";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import useScroll from "../useScroll";
|
import { RootState } from "../state/Store";
|
||||||
|
import { HexKey } from "../nostr";
|
||||||
|
|
||||||
const RootTab = {
|
const RootTab = {
|
||||||
Follows: 0,
|
Follows: 0,
|
||||||
@ -12,9 +13,8 @@ const RootTab = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RootPage() {
|
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 [tab, setTab] = useState(RootTab.Follows);
|
||||||
const [eop] = useScroll();
|
|
||||||
|
|
||||||
function followHints() {
|
function followHints() {
|
||||||
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
|
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
|
||||||
@ -27,7 +27,7 @@ export default function RootPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pubKey ? <>
|
{pubKey ? <>
|
||||||
<NoteCreator show={true}/>
|
<NoteCreator show={true} autoFocus={true} />
|
||||||
<div className="tabs root-tabs">
|
<div className="tabs root-tabs">
|
||||||
<div className={`root-tab f-1 ${tab === RootTab.Follows ? "active" : ""}`} onClick={() => setTab(RootTab.Follows)}>
|
<div className={`root-tab f-1 ${tab === RootTab.Follows ? "active" : ""}`} onClick={() => setTab(RootTab.Follows)}>
|
||||||
Follows
|
Follows
|
@ -1,4 +1,5 @@
|
|||||||
import "./SettingsPage.css";
|
import "./SettingsPage.css";
|
||||||
|
// @ts-ignore
|
||||||
import Nostrich from "../nostrich.jpg";
|
import Nostrich from "../nostrich.jpg";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@ -14,40 +15,44 @@ import { logout, setRelays } from "../state/Login";
|
|||||||
import { hexToBech32, openFile } from "../Util";
|
import { hexToBech32, openFile } from "../Util";
|
||||||
import Relay from "../element/Relay";
|
import Relay from "../element/Relay";
|
||||||
import Copy from "../element/Copy";
|
import Copy from "../element/Copy";
|
||||||
|
import { RootState } from "../state/Store";
|
||||||
|
import { HexKey, UserMetadata } from "../nostr";
|
||||||
|
import { RelaySettings } from "../nostr/Connection";
|
||||||
|
import { MetadataCache } from "../db/User";
|
||||||
|
|
||||||
export default function SettingsPage(props) {
|
export default function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const id = useSelector(s => s.login.publicKey);
|
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||||
const privKey = useSelector(s => s.login.privateKey);
|
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
|
||||||
const relays = useSelector(s => s.login.relays);
|
const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const user = useProfile(id);
|
const user = useProfile(id)?.get(id || "");
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState<string>();
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState<string>();
|
||||||
const [picture, setPicture] = useState("");
|
const [picture, setPicture] = useState<string>();
|
||||||
const [banner, setBanner] = useState("");
|
const [banner, setBanner] = useState<string>();
|
||||||
const [about, setAbout] = useState("");
|
const [about, setAbout] = useState<string>();
|
||||||
const [website, setWebsite] = useState("");
|
const [website, setWebsite] = useState<string>();
|
||||||
const [nip05, setNip05] = useState("");
|
const [nip05, setNip05] = useState<string>();
|
||||||
const [lud06, setLud06] = useState("");
|
const [lud06, setLud06] = useState<string>();
|
||||||
const [lud16, setLud16] = useState("");
|
const [lud16, setLud16] = useState<string>();
|
||||||
const [newRelay, setNewRelay] = useState("");
|
const [newRelay, setNewRelay] = useState<string>();
|
||||||
|
|
||||||
const avatarPicture = picture.length === 0 ? Nostrich : picture
|
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setName(user.name ?? "");
|
setName(user.name);
|
||||||
setDisplayName(user.display_name ?? "")
|
setDisplayName(user.display_name)
|
||||||
setPicture(user.picture ?? "");
|
setPicture(user.picture);
|
||||||
setBanner(user.banner ?? "");
|
setBanner(user.banner);
|
||||||
setAbout(user.about ?? "");
|
setAbout(user.about);
|
||||||
setWebsite(user.website ?? "");
|
setWebsite(user.website);
|
||||||
setNip05(user.nip05 ?? "");
|
setNip05(user.nip05);
|
||||||
setLud06(user.lud06 ?? "");
|
setLud06(user.lud06);
|
||||||
setLud16(user.lud16 ?? "");
|
setLud16(user.lud16);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@ -65,23 +70,8 @@ export default function SettingsPage(props) {
|
|||||||
lud16
|
lud16
|
||||||
};
|
};
|
||||||
delete userCopy["loaded"];
|
delete userCopy["loaded"];
|
||||||
delete userCopy["fromEvent"];
|
delete userCopy["created"];
|
||||||
// event top level props should not be copied into metadata (bug)
|
|
||||||
delete userCopy["pubkey"];
|
delete userCopy["pubkey"];
|
||||||
delete userCopy["sig"];
|
|
||||||
delete userCopy["pubkey"];
|
|
||||||
delete userCopy["tags"];
|
|
||||||
delete userCopy["content"];
|
|
||||||
delete userCopy["created_at"];
|
|
||||||
delete userCopy["id"];
|
|
||||||
delete userCopy["kind"]
|
|
||||||
|
|
||||||
// trim empty string fields
|
|
||||||
Object.keys(userCopy).forEach(k => {
|
|
||||||
if (userCopy[k] === "") {
|
|
||||||
delete userCopy[k];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.debug(userCopy);
|
console.debug(userCopy);
|
||||||
|
|
||||||
let ev = await publisher.metadata(userCopy);
|
let ev = await publisher.metadata(userCopy);
|
||||||
@ -91,22 +81,28 @@ export default function SettingsPage(props) {
|
|||||||
|
|
||||||
async function uploadFile() {
|
async function uploadFile() {
|
||||||
let file = await openFile();
|
let file = await openFile();
|
||||||
|
if (file) {
|
||||||
console.log(file);
|
console.log(file);
|
||||||
let rsp = await VoidUpload(file);
|
let rsp = await VoidUpload(file, file.name);
|
||||||
if (!rsp) {
|
if (!rsp?.ok) {
|
||||||
throw "Upload failed, please try again later";
|
throw "Upload failed, please try again later";
|
||||||
}
|
}
|
||||||
return rsp
|
return rsp.file;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setNewAvatar() {
|
async function setNewAvatar() {
|
||||||
const rsp = await uploadFile()
|
const rsp = await uploadFile();
|
||||||
setPicture(rsp.metadata.url ?? `https://void.cat/d/${rsp.id}`)
|
if (rsp) {
|
||||||
|
setPicture(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setNewBanner() {
|
async function setNewBanner() {
|
||||||
const rsp = await uploadFile()
|
const rsp = await uploadFile();
|
||||||
setBanner(rsp.metadata.url ?? `https://void.cat/d/${rsp.id}`)
|
if (rsp) {
|
||||||
|
setBanner(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRelays() {
|
async function saveRelays() {
|
||||||
@ -171,16 +167,20 @@ export default function SettingsPage(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addRelay() {
|
function addRelay() {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>Add Relays</h4>
|
<h4>Add Relays</h4>
|
||||||
<div className="flex mb10">
|
<div className="flex mb10">
|
||||||
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
|
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="btn mb10" onClick={() => dispatch(setRelays({ [newRelay]: { read: false, write: false } }))}>Add</div>
|
<div className="btn mb10" onClick={() => dispatch(setRelays(payload))}>Add</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function settings() {
|
function settings() {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
@ -196,7 +196,7 @@ export default function SettingsPage(props) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Header</h2>
|
<h2>Header</h2>
|
||||||
<div style={{ backgroundImage: `url(${banner.length === 0 ? avatarPicture : banner})` }} className="banner">
|
<div style={{ backgroundImage: `url(${(banner?.length ?? 0) === 0 ? avatarPicture : banner})` }} className="banner">
|
||||||
<div className="edit" onClick={() => setNewBanner()}>Edit</div>
|
<div className="edit" onClick={() => setNewBanner()}>Edit</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -27,7 +27,7 @@ interface LoginStore {
|
|||||||
/**
|
/**
|
||||||
* All the logged in users relays
|
* All the logged in users relays
|
||||||
*/
|
*/
|
||||||
relays: any,
|
relays: Record<string, RelaySettings>,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Newest relay list timestamp
|
* Newest relay list timestamp
|
||||||
@ -56,7 +56,7 @@ interface LoginStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface SetRelaysPayload {
|
export interface SetRelaysPayload {
|
||||||
relays: any,
|
relays: Record<string, RelaySettings>,
|
||||||
createdAt: number
|
createdAt: number
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ export const useCopy = (timeout = 2000) => {
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const copy = async (text) => {
|
const copy = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(true)
|
setCopied(true)
|
Loading…
Reference in New Issue
Block a user