Add prettier formatting (#214)

* chore: add prettier

* chore: format codebase
This commit is contained in:
ennmichael
2023-02-07 21:04:50 +01:00
committed by GitHub
parent 015f799cf7
commit 5ad4971fc0
182 changed files with 8686 additions and 6861 deletions

View File

@ -1,33 +1,33 @@
.dm-list {
overflow-y: auto;
overflow-x: hidden;
height: calc(100vh - 66px - 50px - 70px);
overflow-y: auto;
overflow-x: hidden;
height: calc(100vh - 66px - 50px - 70px);
}
.dm-list > div {
display: flex;
flex-direction: column;
margin-bottom: 10px;
scroll-padding-bottom: 40px;
display: flex;
flex-direction: column;
margin-bottom: 10px;
scroll-padding-bottom: 40px;
}
.write-dm {
position: fixed;
bottom: 0;
background-color: var(--gray-light);
width: inherit;
border-radius: 5px 5px 0 0;
position: fixed;
bottom: 0;
background-color: var(--gray-light);
width: inherit;
border-radius: 5px 5px 0 0;
}
.write-dm .inner {
display: flex;
align-items: center;
padding: 10px 5px;
display: flex;
align-items: center;
padding: 10px 5px;
}
.write-dm textarea {
resize: none;
resize: none;
}
.write-dm-spacer {
margin-bottom: 80px;
margin-bottom: 80px;
}

View File

@ -2,7 +2,7 @@ import "./ChatPage.css";
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import { useInView } from 'react-intersection-observer';
import { useInView } from "react-intersection-observer";
import ProfileImage from "Element/ProfileImage";
import { bech32ToHex } from "Util";
@ -14,68 +14,78 @@ import { dmsInChat, isToSelf } from "Pages/MessagesPage";
import NoteToSelf from "Element/NoteToSelf";
type RouterParams = {
id: string
}
id: string;
};
export default function ChatPage() {
const params = useParams<RouterParams>();
const publisher = useEventPublisher();
const id = bech32ToHex(params.id ?? "");
const pubKey = useSelector<any>(s => s.login.publicKey);
const dms = useSelector<any, RawEvent[]>(s => filterDms(s.login.dms));
const [content, setContent] = useState<string>();
const { ref, inView, entry } = useInView();
const dmListRef = useRef<HTMLDivElement>(null);
const params = useParams<RouterParams>();
const publisher = useEventPublisher();
const id = bech32ToHex(params.id ?? "");
const pubKey = useSelector<any>((s) => s.login.publicKey);
const dms = useSelector<any, RawEvent[]>((s) => filterDms(s.login.dms));
const [content, setContent] = useState<string>();
const { ref, inView, entry } = useInView();
const dmListRef = useRef<HTMLDivElement>(null);
function filterDms(dms: RawEvent[]) {
return dmsInChat(id === pubKey ? dms.filter(d => isToSelf(d, pubKey)) : dms, id);
function filterDms(dms: RawEvent[]) {
return dmsInChat(
id === pubKey ? dms.filter((d) => isToSelf(d, pubKey)) : dms,
id
);
}
const sortedDms = useMemo<any[]>(() => {
return [...dms].sort((a, b) => a.created_at - b.created_at);
}, [dms]);
useEffect(() => {
if (inView && dmListRef.current) {
dmListRef.current.scroll(0, dmListRef.current.scrollHeight);
}
}, [inView, dmListRef, sortedDms]);
const sortedDms = useMemo<any[]>(() => {
return [...dms].sort((a, b) => a.created_at - b.created_at)
}, [dms]);
useEffect(() => {
if (inView && dmListRef.current) {
dmListRef.current.scroll(0, dmListRef.current.scrollHeight);
}
}, [inView, dmListRef, sortedDms]);
async function sendDm() {
if (content) {
let ev = await publisher.sendDm(content, id);
console.debug(ev);
publisher.broadcast(ev);
setContent("");
}
async function sendDm() {
if (content) {
let ev = await publisher.sendDm(content, id);
console.debug(ev);
publisher.broadcast(ev);
setContent("");
}
}
async function onEnter(e: KeyboardEvent) {
let isEnter = e.code === "Enter";
if (isEnter && !e.shiftKey) {
await sendDm();
}
async function onEnter(e: KeyboardEvent) {
let isEnter = e.code === "Enter";
if (isEnter && !e.shiftKey) {
await sendDm();
}
}
return (
<>
{id === pubKey && (
<NoteToSelf className="f-grow mb-10" pubkey={id} />
) || (
<ProfileImage pubkey={id} className="f-grow mb10" />
)}
<div className="dm-list" ref={dmListRef}>
<div>
{sortedDms.map(a => <DM data={a} key={a.id} />)}
<div ref={ref} className="mb10"></div>
</div>
</div>
<div className="write-dm">
<div className="inner">
<textarea className="f-grow mr10" value={content} onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => onEnter(e)}></textarea>
<button type="button" onClick={() => sendDm()}>Send</button>
</div>
</div>
</>
)
return (
<>
{(id === pubKey && (
<NoteToSelf className="f-grow mb-10" pubkey={id} />
)) || <ProfileImage pubkey={id} className="f-grow mb10" />}
<div className="dm-list" ref={dmListRef}>
<div>
{sortedDms.map((a) => (
<DM data={a} key={a.id} />
))}
<div ref={ref} className="mb10"></div>
</div>
</div>
<div className="write-dm">
<div className="inner">
<textarea
className="f-grow mr10"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => onEnter(e)}
></textarea>
<button type="button" onClick={() => sendDm()}>
Send
</button>
</div>
</div>
</>
);
}

View File

@ -6,83 +6,109 @@ import { useEffect, useState } from "react";
import { bech32ToHex } from "Util";
const Developers = [
bech32ToHex(KieranPubKey), // kieran
bech32ToHex("npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg"), // verbiricha
bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), // Karnage
bech32ToHex(KieranPubKey), // kieran
bech32ToHex(
"npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg"
), // verbiricha
bech32ToHex(
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"
), // Karnage
];
const Contributors = [
bech32ToHex("npub10djxr5pvdu97rjkde7tgcsjxzpdzmdguwacfjwlchvj7t88dl7nsdl54nf"), // ivan
bech32ToHex("npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"), // liran cohen
bech32ToHex("npub1xdtducdnjerex88gkg2qk2atsdlqsyxqaag4h05jmcpyspqt30wscmntxy"), // artur
bech32ToHex(
"npub10djxr5pvdu97rjkde7tgcsjxzpdzmdguwacfjwlchvj7t88dl7nsdl54nf"
), // ivan
bech32ToHex(
"npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"
), // liran cohen
bech32ToHex(
"npub1xdtducdnjerex88gkg2qk2atsdlqsyxqaag4h05jmcpyspqt30wscmntxy"
), // artur
];
interface Splits {
pubKey: string,
split: number
pubKey: string;
split: number;
}
interface TotalToday {
donations: number,
nip5: number
donations: number;
nip5: number;
}
const DonatePage = () => {
const [splits, setSplits] = useState<Splits[]>([]);
const [today, setSumToday] = useState<TotalToday>();
const [splits, setSplits] = useState<Splits[]>([]);
const [today, setSumToday] = useState<TotalToday>();
async function loadData() {
let rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
if(rsp.ok) {
setSplits(await rsp.json());
}
let rsp2 = await fetch(`${ApiHost}/api/v1/revenue/today`);
if(rsp2.ok) {
setSumToday(await rsp2.json());
}
async function loadData() {
let rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
if (rsp.ok) {
setSplits(await rsp.json());
}
useEffect(() => {
loadData().catch(console.warn);
}, []);
function actions(pk: HexKey) {
let split = splits.find(a => bech32ToHex(a.pubKey) === pk);
if(split) {
return <>{(100 * split.split).toLocaleString()}%</>
}
return <></>
let rsp2 = await fetch(`${ApiHost}/api/v1/revenue/today`);
if (rsp2.ok) {
setSumToday(await rsp2.json());
}
return (
<div className="main-content m5">
<h2>Help fund the development of Snort</h2>
<p>
Snort is an open source project built by passionate people in their free time
</p>
<p>
Your donations are greatly appreciated
</p>
<p>
Check out the code here: <a className="highlight" href="https://github.com/v0l/snort" rel="noreferrer" target="_blank">https://github.com/v0l/snort</a>
</p>
<p>
Each contributor will get paid a percentage of all donations and NIP-05 orders, you can see the split amounts below
</p>
<div className="flex">
<div className="mr10">Lightning Donation: </div>
<ZapButton
pubkey={bech32ToHex(SnortPubKey)}
svc={"donate@snort.social"}
/>
</div>
{today && (<small>Total today (UTC): {today.donations.toLocaleString()} sats</small>)}
<h3>Primary Developers</h3>
{Developers.map(a => <ProfilePreview pubkey={a} key={a} actions={actions(a)} />)}
<h4>Contributors</h4>
{Contributors.map(a => <ProfilePreview pubkey={a} key={a} actions={actions(a)} />)}
</div>
);
}
}
useEffect(() => {
loadData().catch(console.warn);
}, []);
function actions(pk: HexKey) {
let split = splits.find((a) => bech32ToHex(a.pubKey) === pk);
if (split) {
return <>{(100 * split.split).toLocaleString()}%</>;
}
return <></>;
}
return (
<div className="main-content m5">
<h2>Help fund the development of Snort</h2>
<p>
Snort is an open source project built by passionate people in their free
time
</p>
<p>Your donations are greatly appreciated</p>
<p>
Check out the code here:{" "}
<a
className="highlight"
href="https://github.com/v0l/snort"
rel="noreferrer"
target="_blank"
>
https://github.com/v0l/snort
</a>
</p>
<p>
Each contributor will get paid a percentage of all donations and NIP-05
orders, you can see the split amounts below
</p>
<div className="flex">
<div className="mr10">Lightning Donation: </div>
<ZapButton
pubkey={bech32ToHex(SnortPubKey)}
svc={"donate@snort.social"}
/>
</div>
{today && (
<small>
Total today (UTC): {today.donations.toLocaleString()} sats
</small>
)}
<h3>Primary Developers</h3>
{Developers.map((a) => (
<ProfilePreview pubkey={a} key={a} actions={actions(a)} />
))}
<h4>Contributors</h4>
{Contributors.map((a) => (
<ProfilePreview pubkey={a} key={a} actions={actions(a)} />
))}
</div>
);
};
export default DonatePage;

View File

@ -1,17 +1,15 @@
import { useRouteError } from "react-router-dom";
const ErrorPage = () => {
const error = useRouteError();
const error = useRouteError();
console.error(error);
return (
<>
<h4>An error has occured!</h4>
<pre>
{JSON.stringify(error, undefined, ' ')}
</pre>
</>
);
console.error(error);
return (
<>
<h4>An error has occured!</h4>
<pre>{JSON.stringify(error, undefined, " ")}</pre>
</>
);
};
export default ErrorPage;

View File

@ -4,9 +4,9 @@ import useThreadFeed from "Feed/ThreadFeed";
import { parseId } from "Util";
export default function EventPage() {
const params = useParams();
const id = parseId(params.id!);
const thread = useThreadFeed(id);
const params = useParams();
const id = parseId(params.id!);
const thread = useThreadFeed(id);
return <Thread notes={thread.notes} this={id} />;
}
return <Thread notes={thread.notes} this={id} />;
}

View File

@ -2,15 +2,20 @@ import { useParams } from "react-router-dom";
import Timeline from "Element/Timeline";
const HashTagsPage = () => {
const params = useParams();
const tag = params.tag!.toLowerCase();
const params = useParams();
const tag = params.tag!.toLowerCase();
return (
<>
<h2>#{tag}</h2>
<Timeline key={tag} subject={{ type: "hashtag", items: [tag], discriminator: tag }} postsOnly={false} method={"TIME_RANGE"} />
</>
)
}
return (
<>
<h2>#{tag}</h2>
<Timeline
key={tag}
subject={{ type: "hashtag", items: [tag], discriminator: tag }}
postsOnly={false}
method={"TIME_RANGE"}
/>
</>
);
};
export default HashTagsPage;
export default HashTagsPage;

View File

@ -2,12 +2,13 @@ import { Link } from "react-router-dom";
import { KieranPubKey } from "Const";
export default function HelpPage() {
return (
<>
<h2>NIP-05</h2>
<p>
If you have an enquiry about your NIP-05 order please DM <Link to={`/messages/${KieranPubKey}`}>Kieran</Link>
</p>
</>
)
}
return (
<>
<h2>NIP-05</h2>
<p>
If you have an enquiry about your NIP-05 order please DM{" "}
<Link to={`/messages/${KieranPubKey}`}>Kieran</Link>
</p>
</>
);
}

View File

@ -1,5 +1,5 @@
import "./Layout.css";
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import Envelope from "Icons/Envelope";
@ -8,11 +8,11 @@ import Search from "Icons/Search";
import { RootState } from "State/Store";
import { init, setRelays } from "State/Login";
import { System } from "Nostr/System"
import { System } from "Nostr/System";
import ProfileImage from "Element/ProfileImage";
import useLoginFeed from "Feed/LoginFeed";
import { totalUnread } from "Pages/MessagesPage";
import { SearchRelays, SnortPubKey } from 'Const';
import { SearchRelays, SnortPubKey } from "Const";
import useEventPublisher from "Feed/EventPublisher";
import useModeration from "Hooks/useModeration";
import { IndexedUDB, useDb } from "State/Users/Db";
@ -23,178 +23,229 @@ import Plus from "Icons/Plus";
import { RelaySettings } from "Nostr/Connection";
export default function Layout() {
const location = useLocation();
const [show, setShow] = useState(false)
const dispatch = useDispatch();
const navigate = useNavigate();
const { loggedOut, publicKey, relays, latestNotification, readNotifications, dms, preferences, newUserKey } = useSelector((s: RootState) => s.login);
const { isMuted } = useModeration();
const location = useLocation();
const [show, setShow] = useState(false);
const dispatch = useDispatch();
const navigate = useNavigate();
const {
loggedOut,
publicKey,
relays,
latestNotification,
readNotifications,
dms,
preferences,
newUserKey,
} = useSelector((s: RootState) => s.login);
const { isMuted } = useModeration();
const usingDb = useDb();
const pub = useEventPublisher();
useLoginFeed();
const usingDb = useDb();
const pub = useEventPublisher();
useLoginFeed();
const shouldHideNoteCreator = useMemo(() => {
const hideNoteCreator = ["/settings", "/messages", "/new"]
return hideNoteCreator.some(a => location.pathname.startsWith(a));
}, [location]);
const shouldHideNoteCreator = useMemo(() => {
const hideNoteCreator = ["/settings", "/messages", "/new"];
return hideNoteCreator.some((a) => location.pathname.startsWith(a));
}, [location]);
const hasNotifications = useMemo(() => latestNotification > readNotifications, [latestNotification, readNotifications]);
const unreadDms = useMemo(() => publicKey ? totalUnread(dms.filter(a => !isMuted(a.pubkey)), publicKey) : 0, [dms, publicKey]);
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
[latestNotification, readNotifications]
);
const unreadDms = useMemo(
() =>
publicKey
? totalUnread(
dms.filter((a) => !isMuted(a.pubkey)),
publicKey
)
: 0,
[dms, publicKey]
);
useEffect(() => {
System.nip42Auth = pub.nip42Auth
}, [pub])
useEffect(() => {
System.nip42Auth = pub.nip42Auth;
}, [pub]);
useEffect(() => {
System.UserDb = usingDb;
}, [usingDb])
useEffect(() => {
System.UserDb = usingDb;
}, [usingDb]);
useEffect(() => {
if (relays) {
for (let [k, v] of Object.entries(relays)) {
System.ConnectToRelay(k, v);
}
for (let [k] of System.Sockets) {
if (!relays[k] && !SearchRelays.has(k)) {
System.DisconnectRelay(k);
}
}
useEffect(() => {
if (relays) {
for (let [k, v] of Object.entries(relays)) {
System.ConnectToRelay(k, v);
}
for (let [k] of System.Sockets) {
if (!relays[k] && !SearchRelays.has(k)) {
System.DisconnectRelay(k);
}
}, [relays]);
}
}
}, [relays]);
function setTheme(theme: "light" | "dark") {
const elm = document.documentElement;
if (theme === "light" && !elm.classList.contains("light")) {
elm.classList.add("light");
} else if (theme === "dark" && elm.classList.contains("light")) {
elm.classList.remove("light");
}
function setTheme(theme: "light" | "dark") {
const elm = document.documentElement;
if (theme === "light" && !elm.classList.contains("light")) {
elm.classList.add("light");
} else if (theme === "dark" && elm.classList.contains("light")) {
elm.classList.remove("light");
}
}
useEffect(() => {
let osTheme = window.matchMedia("(prefers-color-scheme: light)");
setTheme(
preferences.theme === "system" && osTheme.matches
? "light"
: preferences.theme === "light"
? "light"
: "dark"
);
osTheme.onchange = (e) => {
if (preferences.theme === "system") {
setTheme(e.matches ? "light" : "dark");
}
};
return () => {
osTheme.onchange = null;
};
}, [preferences.theme]);
useEffect(() => {
// check DB support then init
IndexedUDB.isAvailable().then(async (a) => {
const dbType = a ? "indexdDb" : "redux";
// cleanup on load
if (dbType === "indexdDb") {
await db.feeds.clear();
const now = Math.floor(new Date().getTime() / 1000);
const cleanupEvents = await db.events
.where("created_at")
.above(now - 60 * 60)
.primaryKeys();
console.debug(`Cleanup ${cleanupEvents.length} events`);
await db.events.bulkDelete(cleanupEvents);
}
console.debug(`Using db: ${dbType}`);
dispatch(init(dbType));
});
}, []);
async function handleNewUser() {
let newRelays: Record<string, RelaySettings> | undefined;
try {
let rsp = await fetch("https://api.nostr.watch/v1/online");
if (rsp.ok) {
let online: string[] = await rsp.json();
let pickRandom = online
.sort((a, b) => (Math.random() >= 0.5 ? 1 : -1))
.slice(0, 4); // pick 4 random relays
let relayObjects = pickRandom.map((a) => [
a,
{ read: true, write: true },
]);
newRelays = Object.fromEntries(relayObjects);
dispatch(
setRelays({
relays: newRelays!,
createdAt: 1,
})
);
}
} catch (e) {
console.warn(e);
}
useEffect(() => {
let osTheme = window.matchMedia("(prefers-color-scheme: light)");
setTheme(preferences.theme === "system" && osTheme.matches ? "light" : preferences.theme === "light" ? "light" : "dark");
const ev = await pub.addFollow(bech32ToHex(SnortPubKey), newRelays);
pub.broadcast(ev);
}
osTheme.onchange = (e) => {
if (preferences.theme === "system") {
setTheme(e.matches ? "light" : "dark");
}
}
return () => { osTheme.onchange = null; }
}, [preferences.theme]);
useEffect(() => {
// check DB support then init
IndexedUDB.isAvailable()
.then(async a => {
const dbType = a ? "indexdDb" : "redux";
// cleanup on load
if (dbType === "indexdDb") {
await db.feeds.clear();
const now = Math.floor(new Date().getTime() / 1000);
const cleanupEvents = await db.events
.where("created_at")
.above(now - (60 * 60))
.primaryKeys();
console.debug(`Cleanup ${cleanupEvents.length} events`);
await db.events.bulkDelete(cleanupEvents)
}
console.debug(`Using db: ${dbType}`);
dispatch(init(dbType));
})
}, []);
async function handleNewUser() {
let newRelays: Record<string, RelaySettings> | undefined;
try {
let rsp = await fetch("https://api.nostr.watch/v1/online");
if (rsp.ok) {
let online: string[] = await rsp.json();
let pickRandom = online.sort((a, b) => Math.random() >= 0.5 ? 1 : -1).slice(0, 4); // pick 4 random relays
let relayObjects = pickRandom.map(a => [a, { read: true, write: true }]);
newRelays = Object.fromEntries(relayObjects);
dispatch(setRelays({
relays: newRelays!,
createdAt: 1
}));
}
} catch (e) {
console.warn(e);
}
const ev = await pub.addFollow(bech32ToHex(SnortPubKey), newRelays);
pub.broadcast(ev);
useEffect(() => {
if (newUserKey === true) {
handleNewUser().catch(console.warn);
}
}, [newUserKey]);
useEffect(() => {
if (newUserKey === true) {
handleNewUser().catch(console.warn);
async function goToNotifications(e: any) {
e.stopPropagation();
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
let res = await Notification.requestPermission();
console.debug(res);
}
}, [newUserKey]);
async function goToNotifications(e: any) {
e.stopPropagation();
// request permissions to send notifications
if ("Notification" in window) {
try {
if (Notification.permission !== "granted") {
let res = await Notification.requestPermission();
console.debug(res);
}
} catch (e) {
console.error(e);
}
}
navigate("/notifications");
} catch (e) {
console.error(e);
}
}
navigate("/notifications");
}
function accountHeader() {
return (
<div className="header-actions">
<div className="btn btn-rnd" onClick={(e) => navigate("/search")}>
<Search />
</div>
<div className="btn btn-rnd" onClick={(e) => navigate("/messages")}>
<Envelope />
{unreadDms > 0 && (<span className="has-unread"></span>)}
</div>
<div className="btn btn-rnd" onClick={(e) => goToNotifications(e)}>
<Bell />
{hasNotifications && (<span className="has-unread"></span>)}
</div>
<ProfileImage pubkey={publicKey || ""} showUsername={false} />
</div>
)
}
if (typeof loggedOut !== "boolean") {
return null;
}
function accountHeader() {
return (
<div className="page">
<header>
<div className="logo" onClick={() => navigate("/")}>Snort</div>
<div>
{publicKey ? accountHeader() :
<button type="button" onClick={() => navigate("/login")}>Login</button>
}
</div>
</header>
<Outlet />
{!shouldHideNoteCreator && (<>
<button className="note-create-button" type="button" onClick={() => setShow(!show)}>
<Plus />
</button>
<NoteCreator replyTo={undefined} autoFocus={true} show={show} setShow={setShow} />
</>)}
<div className="header-actions">
<div className="btn btn-rnd" onClick={(e) => navigate("/search")}>
<Search />
</div>
)
<div className="btn btn-rnd" onClick={(e) => navigate("/messages")}>
<Envelope />
{unreadDms > 0 && <span className="has-unread"></span>}
</div>
<div className="btn btn-rnd" onClick={(e) => goToNotifications(e)}>
<Bell />
{hasNotifications && <span className="has-unread"></span>}
</div>
<ProfileImage pubkey={publicKey || ""} showUsername={false} />
</div>
);
}
if (typeof loggedOut !== "boolean") {
return null;
}
return (
<div className="page">
<header>
<div className="logo" onClick={() => navigate("/")}>
Snort
</div>
<div>
{publicKey ? (
accountHeader()
) : (
<button type="button" onClick={() => navigate("/login")}>
Login
</button>
)}
</div>
</header>
<Outlet />
{!shouldHideNoteCreator && (
<>
<button
className="note-create-button"
type="button"
onClick={() => setShow(!show)}
>
<Plus />
</button>
<NoteCreator
replyTo={undefined}
autoFocus={true}
show={show}
setShow={setShow}
/>
</>
)}
</div>
);
}

View File

@ -1,119 +1,142 @@
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import * as secp from '@noble/secp256k1';
import * as secp from "@noble/secp256k1";
import { RootState } from "State/Store";
import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login";
import {
setPrivateKey,
setPublicKey,
setRelays,
setGeneratedPrivateKey,
} from "State/Login";
import { DefaultRelays, EmailRegex } from "Const";
import { bech32ToHex } from "Util";
import { HexKey } from "Nostr";
export default function LoginPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const publicKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const [key, setKey] = useState("");
const [error, setError] = useState("");
const dispatch = useDispatch();
const navigate = useNavigate();
const publicKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const [key, setKey] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (publicKey) {
navigate("/");
}
}, [publicKey, navigate]);
async function getNip05PubKey(addr: string) {
let [username, domain] = addr.split("@");
let rsp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(username)}`);
if (rsp.ok) {
let data = await rsp.json();
let pKey = data.names[username];
if (pKey) {
return pKey;
}
}
throw new Error("User key not found")
useEffect(() => {
if (publicKey) {
navigate("/");
}
}, [publicKey, navigate]);
async function doLogin() {
try {
if (key.startsWith("nsec")) {
let hexKey = bech32ToHex(key);
if (secp.utils.isValidPrivateKey(hexKey)) {
dispatch(setPrivateKey(hexKey));
} else {
throw new Error("INVALID PRIVATE KEY");
}
} else if (key.startsWith("npub")) {
let hexKey = bech32ToHex(key);
dispatch(setPublicKey(hexKey));
} else if (key.match(EmailRegex)) {
let hexKey = await getNip05PubKey(key);
dispatch(setPublicKey(hexKey));
} else {
if (secp.utils.isValidPrivateKey(key)) {
dispatch(setPrivateKey(key));
} else {
throw new Error("INVALID PRIVATE KEY");
}
}
} catch (e) {
setError(`Failed to load NIP-05 pub key (${e})`);
console.error(e);
}
async function getNip05PubKey(addr: string) {
let [username, domain] = addr.split("@");
let rsp = await fetch(
`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(
username
)}`
);
if (rsp.ok) {
let data = await rsp.json();
let pKey = data.names[username];
if (pKey) {
return pKey;
}
}
throw new Error("User key not found");
}
async function makeRandomKey() {
let newKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey());
dispatch(setGeneratedPrivateKey(newKey));
navigate("/new");
}
async function doNip07Login() {
let pubKey = await window.nostr.getPublicKey();
dispatch(setPublicKey(pubKey));
if ("getRelays" in window.nostr) {
let relays = await window.nostr.getRelays();
dispatch(setRelays({
relays: {
...relays,
...Object.fromEntries(DefaultRelays.entries())
},
createdAt: 1
}));
async function doLogin() {
try {
if (key.startsWith("nsec")) {
let hexKey = bech32ToHex(key);
if (secp.utils.isValidPrivateKey(hexKey)) {
dispatch(setPrivateKey(hexKey));
} else {
throw new Error("INVALID PRIVATE KEY");
}
}
function altLogins() {
let nip07 = 'nostr' in window;
if (!nip07) {
return null;
} else if (key.startsWith("npub")) {
let hexKey = bech32ToHex(key);
dispatch(setPublicKey(hexKey));
} else if (key.match(EmailRegex)) {
let hexKey = await getNip05PubKey(key);
dispatch(setPublicKey(hexKey));
} else {
if (secp.utils.isValidPrivateKey(key)) {
dispatch(setPrivateKey(key));
} else {
throw new Error("INVALID PRIVATE KEY");
}
}
} catch (e) {
setError(`Failed to load NIP-05 pub key (${e})`);
console.error(e);
}
}
return (
<>
<h2>Other Login Methods</h2>
<div className="flex">
<button type="button" onClick={(e) => doNip07Login()}>Login with Extension (NIP-07)</button>
</div>
</>
)
async function makeRandomKey() {
let newKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey());
dispatch(setGeneratedPrivateKey(newKey));
navigate("/new");
}
async function doNip07Login() {
let pubKey = await window.nostr.getPublicKey();
dispatch(setPublicKey(pubKey));
if ("getRelays" in window.nostr) {
let relays = await window.nostr.getRelays();
dispatch(
setRelays({
relays: {
...relays,
...Object.fromEntries(DefaultRelays.entries()),
},
createdAt: 1,
})
);
}
}
function altLogins() {
let nip07 = "nostr" in window;
if (!nip07) {
return null;
}
return (
<div className="main-content">
<h1>Login</h1>
<div className="flex">
<input type="text" placeholder="nsec / npub / nip-05 / hex private key..." className="f-grow" onChange={e => setKey(e.target.value)} />
</div>
{error.length > 0 ? <b className="error">{error}</b> : null}
<div className="tabs">
<button type="button" onClick={(e) => doLogin()}>Login</button>
<button type="button" onClick={() => makeRandomKey()}>Generate Key</button>
</div>
{altLogins()}
<>
<h2>Other Login Methods</h2>
<div className="flex">
<button type="button" onClick={(e) => doNip07Login()}>
Login with Extension (NIP-07)
</button>
</div>
</>
);
}
return (
<div className="main-content">
<h1>Login</h1>
<div className="flex">
<input
type="text"
placeholder="nsec / npub / nip-05 / hex private key..."
className="f-grow"
onChange={(e) => setKey(e.target.value)}
/>
</div>
{error.length > 0 ? <b className="error">{error}</b> : null}
<div className="tabs">
<button type="button" onClick={(e) => doLogin()}>
Login
</button>
<button type="button" onClick={() => makeRandomKey()}>
Generate Key
</button>
</div>
{altLogins()}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"
import { useDispatch, useSelector } from "react-redux";
import { HexKey, RawEvent } from "Nostr";
import UnreadCount from "Element/UnreadCount";
@ -11,117 +11,150 @@ import NoteToSelf from "Element/NoteToSelf";
import useModeration from "Hooks/useModeration";
type DmChat = {
pubkey: HexKey,
unreadMessages: number,
newestMessage: number
}
pubkey: HexKey;
unreadMessages: number;
newestMessage: number;
};
export default function MessagesPage() {
const dispatch = useDispatch();
const myPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
const dmInteraction = useSelector<RootState, number>(s => s.login.dmInteraction);
const { isMuted } = useModeration();
const dispatch = useDispatch();
const myPubKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const dms = useSelector<RootState, RawEvent[]>((s) => s.login.dms);
const dmInteraction = useSelector<RootState, number>(
(s) => s.login.dmInteraction
);
const { isMuted } = useModeration();
const chats = useMemo(() => {
return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!)
}, [dms, myPubKey, dmInteraction]);
function noteToSelf(chat: DmChat) {
return (
<div className="flex mb10" key={chat.pubkey}>
<NoteToSelf clickable={true} className="f-grow" link={`/messages/${hexToBech32("npub", chat.pubkey)}`} pubkey={chat.pubkey} />
</div>
)
}
function person(chat: DmChat) {
if(chat.pubkey === myPubKey) return noteToSelf(chat)
return (
<div className="flex mb10" key={chat.pubkey}>
<ProfileImage pubkey={chat.pubkey} className="f-grow" link={`/messages/${hexToBech32("npub", chat.pubkey)}`} />
<UnreadCount unread={chat.unreadMessages} />
</div>
)
}
function markAllRead() {
for (let c of chats) {
setLastReadDm(c.pubkey);
}
dispatch(incDmInteraction());
}
const chats = useMemo(() => {
return extractChats(
dms.filter((a) => !isMuted(a.pubkey)),
myPubKey!
);
}, [dms, myPubKey, dmInteraction]);
function noteToSelf(chat: DmChat) {
return (
<div className="main-content">
<div className="flex">
<h3 className="f-grow">Messages</h3>
<button type="button" onClick={() => markAllRead()}>Mark All Read</button>
</div>
{chats.sort((a, b) => {
return a.pubkey === myPubKey ? -1 :
b.pubkey === myPubKey ? 1 :
b.newestMessage - a.newestMessage
}).map(person)}
</div>
)
<div className="flex mb10" key={chat.pubkey}>
<NoteToSelf
clickable={true}
className="f-grow"
link={`/messages/${hexToBech32("npub", chat.pubkey)}`}
pubkey={chat.pubkey}
/>
</div>
);
}
function person(chat: DmChat) {
if (chat.pubkey === myPubKey) return noteToSelf(chat);
return (
<div className="flex mb10" key={chat.pubkey}>
<ProfileImage
pubkey={chat.pubkey}
className="f-grow"
link={`/messages/${hexToBech32("npub", chat.pubkey)}`}
/>
<UnreadCount unread={chat.unreadMessages} />
</div>
);
}
function markAllRead() {
for (let c of chats) {
setLastReadDm(c.pubkey);
}
dispatch(incDmInteraction());
}
return (
<div className="main-content">
<div className="flex">
<h3 className="f-grow">Messages</h3>
<button type="button" onClick={() => markAllRead()}>
Mark All Read
</button>
</div>
{chats
.sort((a, b) => {
return a.pubkey === myPubKey
? -1
: b.pubkey === myPubKey
? 1
: b.newestMessage - a.newestMessage;
})
.map(person)}
</div>
);
}
export function lastReadDm(pk: HexKey) {
let k = `dm:seen:${pk}`;
return parseInt(window.localStorage.getItem(k) ?? "0");
let k = `dm:seen:${pk}`;
return parseInt(window.localStorage.getItem(k) ?? "0");
}
export function setLastReadDm(pk: HexKey) {
const now = Math.floor(new Date().getTime() / 1000);
let current = lastReadDm(pk);
if (current >= now) {
return;
}
const now = Math.floor(new Date().getTime() / 1000);
let current = lastReadDm(pk);
if (current >= now) {
return;
}
let k = `dm:seen:${pk}`;
window.localStorage.setItem(k, now.toString());
let k = `dm:seen:${pk}`;
window.localStorage.setItem(k, now.toString());
}
export function dmTo(e: RawEvent) {
let firstP = e.tags.find(b => b[0] === "p");
return firstP ? firstP[1] : "";
let firstP = e.tags.find((b) => b[0] === "p");
return firstP ? firstP[1] : "";
}
export function isToSelf(e: RawEvent, pk: HexKey) {
return e.pubkey === pk && dmTo(e) === pk;
return e.pubkey === pk && dmTo(e) === pk;
}
export function dmsInChat(dms: RawEvent[], pk: HexKey) {
return dms.filter(a => a.pubkey === pk || dmTo(a) === pk);
return dms.filter((a) => a.pubkey === pk || dmTo(a) === pk);
}
export function totalUnread(dms: RawEvent[], myPubKey: HexKey) {
return extractChats(dms, myPubKey).reduce((acc, v) => acc += v.unreadMessages, 0);
return extractChats(dms, myPubKey).reduce(
(acc, v) => (acc += v.unreadMessages),
0
);
}
function unreadDms(dms: RawEvent[], myPubKey: HexKey, pk: HexKey) {
if (pk === myPubKey) return 0;
let lastRead = lastReadDm(pk);
return dmsInChat(dms, pk).filter(a => a.created_at >= lastRead && a.pubkey !== myPubKey).length;
if (pk === myPubKey) return 0;
let lastRead = lastReadDm(pk);
return dmsInChat(dms, pk).filter(
(a) => a.created_at >= lastRead && a.pubkey !== myPubKey
).length;
}
function newestMessage(dms: RawEvent[], myPubKey: HexKey, pk: HexKey) {
if(pk === myPubKey) {
return dmsInChat(dms.filter(d => isToSelf(d, myPubKey)), pk).reduce((acc, v) => acc = v.created_at > acc ? v.created_at : acc, 0);
}
if (pk === myPubKey) {
return dmsInChat(
dms.filter((d) => isToSelf(d, myPubKey)),
pk
).reduce((acc, v) => (acc = v.created_at > acc ? v.created_at : acc), 0);
}
return dmsInChat(dms, pk).reduce((acc, v) => acc = v.created_at > acc ? v.created_at : acc, 0);
return dmsInChat(dms, pk).reduce(
(acc, v) => (acc = v.created_at > acc ? v.created_at : acc),
0
);
}
export function extractChats(dms: RawEvent[], myPubKey: HexKey) {
const keys = dms.map(a => [a.pubkey, dmTo(a)]).flat();
const filteredKeys = Array.from(new Set<string>(keys));
return filteredKeys.map(a => {
return {
pubkey: a,
unreadMessages: unreadDms(dms, myPubKey, a),
newestMessage: newestMessage(dms, myPubKey, a)
} as DmChat;
})
const keys = dms.map((a) => [a.pubkey, dmTo(a)]).flat();
const filteredKeys = Array.from(new Set<string>(keys));
return filteredKeys.map((a) => {
return {
pubkey: a,
unreadMessages: unreadDms(dms, myPubKey, a),
newestMessage: newestMessage(dms, myPubKey, a),
} as DmChat;
});
}

View File

@ -1,28 +1,33 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux"
import { useDispatch, useSelector } from "react-redux";
import { HexKey } from "Nostr";
import { markNotificationsRead } from "State/Login";
import { RootState } from "State/Store";
import Timeline from "Element/Timeline";
export default function NotificationsPage() {
const dispatch = useDispatch();
const pubkey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const dispatch = useDispatch();
const pubkey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
useEffect(() => {
dispatch(markNotificationsRead());
}, []);
useEffect(() => {
dispatch(markNotificationsRead());
}, []);
return (
<>
{pubkey && (
<Timeline
subject={{ type: "ptag", items: [pubkey!], discriminator: pubkey!.slice(0, 12) }}
postsOnly={false}
method={"TIME_RANGE"}
/>
)}
</>
)
}
return (
<>
{pubkey && (
<Timeline
subject={{
type: "ptag",
items: [pubkey!],
discriminator: pubkey!.slice(0, 12),
}}
postsOnly={false}
method={"TIME_RANGE"}
/>
)}
</>
);
}

View File

@ -35,7 +35,6 @@
margin-right: 0;
}
@media (min-width: 520px) {
.profile .banner {
width: 100%;
@ -57,9 +56,8 @@
overflow: hidden;
}
.profile p {
white-space: pre-wrap;
white-space: pre-wrap;
}
.details-wrapper > .name > h2 {
@ -101,28 +99,28 @@
}
.profile .details p {
word-break: break-word;
word-break: break-word;
}
.profile .details a {
color: var(--highlight);
text-decoration: none;
color: var(--highlight);
text-decoration: none;
}
.profile .details a:hover {
text-decoration: underline;
text-decoration: underline;
}
.profile .btn-icon {
color: var(--font-color);
padding: 6px;
color: var(--font-color);
padding: 6px;
}
.profile .details-wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
width: calc(100% - 32px);
display: flex;
flex-direction: column;
justify-content: space-between;
width: calc(100% - 32px);
}
.profile .details .text {
@ -154,7 +152,7 @@
}
.profile .website a {
text-decoration: none;
text-decoration: none;
}
.profile .website a:hover {
@ -162,7 +160,7 @@
}
.profile .lnurl {
cursor: pointer;
cursor: pointer;
}
.profile .ln-address {
@ -172,12 +170,12 @@
}
.profile .lnurl:hover {
text-decoration: underline;
text-decoration: underline;
}
.profile .lnurl {
overflow: hidden;
text-overflow: ellipsis;
overflow: hidden;
text-overflow: ellipsis;
}
.profile .link-icon {

View File

@ -18,7 +18,7 @@ import { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar";
import LogoutButton from "Element/LogoutButton";
import Timeline from "Element/Timeline";
import Text from 'Element/Text'
import Text from "Element/Text";
import SendSats from "Element/SendSats";
import Nip05 from "Element/Nip05";
import Copy from "Element/Copy";
@ -31,10 +31,10 @@ import FollowsList from "Element/FollowsList";
import IconButton from "Element/IconButton";
import { RootState } from "State/Store";
import { HexKey } from "Nostr";
import FollowsYou from "Element/FollowsYou"
import FollowsYou from "Element/FollowsYou";
import QrCode from "Element/QrCode";
import Modal from "Element/Modal";
import { ProxyImg } from "Element/ProxyImg"
import { ProxyImg } from "Element/ProxyImg";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
const ProfileTab = {
@ -45,34 +45,46 @@ const ProfileTab = {
Zaps: { text: "Zaps", value: 4 },
Muted: { text: "Muted", value: 5 },
Blocked: { text: "Blocked", value: 6 },
}
};
export default function ProfilePage() {
const params = useParams();
const navigate = useNavigate();
const id = useMemo(() => parseId(params.id!), [params]);
const user = useUserProfile(id);
const loggedOut = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut);
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const loggedOut = useSelector<RootState, boolean | undefined>(
(s) => s.login.loggedOut
);
const loginPubKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const follows = useSelector<RootState, HexKey[]>((s) => s.login.follows);
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [tab, setTab] = useState<Tab>(ProfileTab.Notes);
const [showProfileQr, setShowProfileQr] = useState<boolean>(false);
const aboutText = user?.about || ''
const about = Text({ content: aboutText, tags: [], users: new Map(), creator: "" })
const aboutText = user?.about || "";
const about = Text({
content: aboutText,
tags: [],
users: new Map(),
creator: "",
});
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
const website_url = (user?.website && !user.website.startsWith("http"))
? "https://" + user.website
: user?.website || "";
const zapFeed = useZapsFeed(id)
const website_url =
user?.website && !user.website.startsWith("http")
? "https://" + user.website
: user?.website || "";
const zapFeed = useZapsFeed(id);
const zaps = useMemo(() => {
const profileZaps = zapFeed.store.notes.map(parseZap).filter(z => z.valid && z.p === id && !z.e && z.zapper !== id)
profileZaps.sort((a, b) => b.amount - a.amount)
return profileZaps
}, [zapFeed.store, id])
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0)
const horizontalScroll = useHorizontalScroll()
const profileZaps = zapFeed.store.notes
.map(parseZap)
.filter((z) => z.valid && z.p === id && !z.e && z.zapper !== id);
profileZaps.sort((a, b) => b.amount - a.amount);
return profileZaps;
}, [zapFeed.store, id]);
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const horizontalScroll = useHorizontalScroll();
useEffect(() => {
setTab(ProfileTab.Notes);
@ -82,14 +94,14 @@ export default function ProfilePage() {
return (
<div className="name">
<h2>
{user?.display_name || user?.name || 'Nostrich'}
{user?.display_name || user?.name || "Nostrich"}
<FollowsYou pubkey={id} />
</h2>
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
<Copy text={params.id || ""} />
{links()}
</div>
)
);
}
function links() {
@ -100,7 +112,9 @@ export default function ProfilePage() {
<span className="link-icon">
<Link />
</span>
<a href={website_url} target="_blank" rel="noreferrer">{user.website}</a>
<a href={website_url} target="_blank" rel="noreferrer">
{user.website}
</a>
</div>
)}
@ -121,35 +135,44 @@ export default function ProfilePage() {
target={user?.display_name || user?.name}
/>
</div>
)
);
}
function bio() {
return aboutText.length > 0 && (
<>
<div className="details">
{about}
</div>
</>
)
return (
aboutText.length > 0 && (
<>
<div className="details">{about}</div>
</>
)
);
}
function tabContent() {
switch (tab) {
case ProfileTab.Notes:
return <Timeline
key={id}
subject={{ type: "pubkey", items: [id], discriminator: id.slice(0, 12) }}
postsOnly={false}
method={"TIME_RANGE"}
ignoreModeration={true} />;
return (
<Timeline
key={id}
subject={{
type: "pubkey",
items: [id],
discriminator: id.slice(0, 12),
}}
postsOnly={false}
method={"TIME_RANGE"}
ignoreModeration={true}
/>
);
case ProfileTab.Zaps: {
return (
<div className="main-content">
<h4 className="zaps-total">{formatShort(zapsTotal)} sats</h4>
{zaps.map(z => <ZapElement showZapped={false} zap={z} />)}
{zaps.map((z) => (
<ZapElement showZapped={false} zap={z} />
))}
</div>
)
);
}
case ProfileTab.Follows: {
@ -157,7 +180,13 @@ export default function ProfilePage() {
return (
<div className="main-content">
<h4>Following {follows.length}</h4>
{follows.map(a => <ProfilePreview key={a} pubkey={a.toLowerCase()} options={{ about: false }} />)}
{follows.map((a) => (
<ProfilePreview
key={a}
pubkey={a.toLowerCase()}
options={{ about: false }}
/>
))}
</div>
);
} else {
@ -165,13 +194,13 @@ export default function ProfilePage() {
}
}
case ProfileTab.Followers: {
return <FollowersList pubkey={id} />
return <FollowersList pubkey={id} />;
}
case ProfileTab.Muted: {
return isMe ? <BlockList variant="muted" /> : <MutedList pubkey={id} />
return isMe ? <BlockList variant="muted" /> : <MutedList pubkey={id} />;
}
case ProfileTab.Blocked: {
return isMe ? <BlockList variant="blocked" /> : null
return isMe ? <BlockList variant="blocked" /> : null;
}
}
}
@ -181,7 +210,7 @@ export default function ProfilePage() {
<div className="avatar-wrapper">
<Avatar user={user} />
</div>
)
);
}
function renderIcons() {
@ -193,7 +222,11 @@ export default function ProfilePage() {
{showProfileQr && (
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
<ProfileImage pubkey={id} />
<QrCode data={`nostr:${hexToBech32("npub", id)}`} link={undefined} className="m10" />
<QrCode
data={`nostr:${hexToBech32("npub", id)}`}
link={undefined}
className="m10"
/>
</Modal>
)}
{isMe ? (
@ -212,7 +245,11 @@ export default function ProfilePage() {
)}
{!loggedOut && (
<>
<IconButton onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<IconButton
onClick={() =>
navigate(`/messages/${hexToBech32("npub", id)}`)
}
>
<Envelope width={16} height={13} />
</IconButton>
</>
@ -220,7 +257,7 @@ export default function ProfilePage() {
</>
)}
</div>
)
);
}
function userDetails() {
@ -233,28 +270,41 @@ export default function ProfilePage() {
</div>
{bio()}
</div>
)
);
}
function renderTab(v: Tab) {
return <TabElement t={v} tab={tab} setTab={setTab} />
return <TabElement t={v} tab={tab} setTab={setTab} />;
}
const w = window.document.querySelector(".page")?.clientWidth;
return (
<>
<div className="profile flex">
{user?.banner && <ProxyImg alt="banner" className="banner" src={user.banner} size={w} />}
{user?.banner && (
<ProxyImg
alt="banner"
className="banner"
src={user.banner}
size={w}
/>
)}
<div className="profile-wrapper flex">
{avatar()}
{userDetails()}
</div>
</div>
<div className="tabs main-content" ref={horizontalScroll}>
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Zaps, ProfileTab.Muted].map(renderTab)}
{[
ProfileTab.Notes,
ProfileTab.Followers,
ProfileTab.Follows,
ProfileTab.Zaps,
ProfileTab.Muted,
].map(renderTab)}
{isMe && renderTab(ProfileTab.Blocked)}
</div>
{tabContent()}
</>
)
);
}

View File

@ -10,38 +10,52 @@ import { HexKey } from "Nostr";
import { TimelineSubject } from "Feed/TimelineFeed";
const RootTab: Record<string, Tab> = {
Posts: { text: 'Posts', value: 0, },
PostsAndReplies: { text: 'Conversations', value: 1, },
Global: { text: 'Global', value: 2 },
Posts: { text: "Posts", value: 0 },
PostsAndReplies: { text: "Conversations", value: 1 },
Global: { text: "Global", value: 2 },
};
export default function RootPage() {
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<Tab>(RootTab.Posts);
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<Tab>(RootTab.Posts);
function followHints() {
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
return <>
Hmm nothing here.. Checkout <Link to={"/new"}>New users page</Link> to follow some recommended nostrich's!
</>
}
}
const isGlobal = loggedOut || tab.value === RootTab.Global.value;
const timelineSubect: TimelineSubject = isGlobal ? { type: "global", items: [], discriminator: "all" } : { type: "pubkey", items: follows, discriminator: "follows" };
return (
function followHints() {
if (follows?.length === 0 && pubKey && tab !== RootTab.Global) {
return (
<>
<div className="main-content">
{pubKey && <Tabs tabs={[RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global]} tab={tab} setTab={setTab} />}
</div>
{followHints()}
<Timeline
key={tab.value}
subject={timelineSubect}
postsOnly={tab.value === RootTab.Posts.value}
method={"TIME_RANGE"}
window={tab.value === RootTab.Global.value ? 60 : undefined}
/>
Hmm nothing here.. Checkout <Link to={"/new"}>New users page</Link> to
follow some recommended nostrich's!
</>
);
);
}
}
const isGlobal = loggedOut || tab.value === RootTab.Global.value;
const timelineSubect: TimelineSubject = isGlobal
? { type: "global", items: [], discriminator: "all" }
: { type: "pubkey", items: follows, discriminator: "follows" };
return (
<>
<div className="main-content">
{pubKey && (
<Tabs
tabs={[RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global]}
tab={tab}
setTab={setTab}
/>
)}
</div>
{followHints()}
<Timeline
key={tab.value}
subject={timelineSubect}
postsOnly={tab.value === RootTab.Posts.value}
method={"TIME_RANGE"}
window={tab.value === RootTab.Global.value ? 60 : undefined}
/>
</>
);
}

View File

@ -7,49 +7,62 @@ import { SearchRelays } from "Const";
import { System } from "Nostr/System";
const SearchPage = () => {
const params: any = useParams();
const [search, setSearch] = useState<string>();
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
const params: any = useParams();
const [search, setSearch] = useState<string>();
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
useEffect(() => {
if (keyword) {
// "navigate" changing only url
router.navigate(`/search/${encodeURIComponent(keyword)}`)
}
}, [keyword]);
useEffect(() => {
if (keyword) {
// "navigate" changing only url
router.navigate(`/search/${encodeURIComponent(keyword)}`);
}
}, [keyword]);
useEffect(() => {
return debounce(500, () => setKeyword(search));
}, [search]);
useEffect(() => {
return debounce(500, () => setKeyword(search));
}, [search]);
useEffect(() => {
let addedRelays: string[] = [];
for (let [k, v] of SearchRelays) {
if (!System.Sockets.has(k)) {
System.ConnectToRelay(k, v);
addedRelays.push(k);
}
}
return () => {
for (let r of addedRelays) {
System.DisconnectRelay(r);
}
}
}, []);
useEffect(() => {
let addedRelays: string[] = [];
for (let [k, v] of SearchRelays) {
if (!System.Sockets.has(k)) {
System.ConnectToRelay(k, v);
addedRelays.push(k);
}
}
return () => {
for (let r of addedRelays) {
System.DisconnectRelay(r);
}
};
}, []);
return (
<div className="main-content">
<h2>Search</h2>
<div className="flex mb10">
<input type="text" className="f-grow mr10" placeholder="Search.." value={search} onChange={e => setSearch(e.target.value)} />
</div>
{keyword && <Timeline
key={keyword}
subject={{ type: "keyword", items: [keyword], discriminator: keyword }}
postsOnly={false}
method={"TIME_RANGE"} />}
</div>
)
}
return (
<div className="main-content">
<h2>Search</h2>
<div className="flex mb10">
<input
type="text"
className="f-grow mr10"
placeholder="Search.."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{keyword && (
<Timeline
key={keyword}
subject={{
type: "keyword",
items: [keyword],
discriminator: keyword,
}}
postsOnly={false}
method={"TIME_RANGE"}
/>
)}
</div>
);
};
export default SearchPage;

View File

@ -6,35 +6,37 @@ import Preferences from "Pages/settings/Preferences";
import RelayInfo from "Pages/settings/RelayInfo";
export default function SettingsPage() {
const navigate = useNavigate();
const navigate = useNavigate();
return (
<div className="main-content">
<h2 onClick={() => navigate("/settings")} className="pointer">Settings</h2>
<Outlet />
</div>
);
return (
<div className="main-content">
<h2 onClick={() => navigate("/settings")} className="pointer">
Settings
</h2>
<Outlet />
</div>
);
}
export const SettingsRoutes: RouteObject[] = [
{
path: "",
element: <SettingsIndex />
},
{
path: "profile",
element: <Profile />
},
{
path: "relays",
element: <Relay />,
},
{
path: "relays/:id",
element: <RelayInfo />
},
{
path: "preferences",
element: <Preferences />
}
]
{
path: "",
element: <SettingsIndex />,
},
{
path: "profile",
element: <Profile />,
},
{
path: "relays",
element: <Relay />,
},
{
path: "relays/:id",
element: <RelayInfo />,
},
{
path: "preferences",
element: <Preferences />,
},
];

View File

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

View File

@ -1,42 +1,57 @@
import { ApiHost } from "Const";
import Nip5Service from "Element/Nip5Service";
import './Verification.css'
import "./Verification.css";
export default function VerificationPage() {
const services = [
{
name: "Snort",
service: `${ApiHost}/api/v1/n5sp`,
link: "https://snort.social/",
supportLink: "https://snort.social/help",
about: <>Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!</>
},
{
name: "Nostr Plebs",
service: "https://nostrplebs.com/api/v1",
link: "https://nostrplebs.com/",
supportLink: "https://nostrplebs.com/manage",
about: <>
<p>Nostr Plebs is one of the first NIP-05 providers in the space and offers a good collection of domains at reasonable prices</p>
</>
}
];
const services = [
{
name: "Snort",
service: `${ApiHost}/api/v1/n5sp`,
link: "https://snort.social/",
supportLink: "https://snort.social/help",
about: (
<>
Our very own NIP-05 verification service, help support the development
of this site and get a shiny special badge on our site!
</>
),
},
{
name: "Nostr Plebs",
service: "https://nostrplebs.com/api/v1",
link: "https://nostrplebs.com/",
supportLink: "https://nostrplebs.com/manage",
about: (
<>
<p>
Nostr Plebs is one of the first NIP-05 providers in the space and
offers a good collection of domains at reasonable prices
</p>
</>
),
},
];
return (
<div className="main-content verification">
<h2>Get Verified</h2>
<p>
NIP-05 is a DNS based verification spec which helps to validate you as a real user.
</p>
<p>Getting NIP-05 verified can help:</p>
<ul>
<li>Prevent fake accounts from imitating you</li>
<li>Make your profile easier to find and share</li>
<li>Fund developers and platforms providing NIP-05 verification services</li>
</ul>
return (
<div className="main-content verification">
<h2>Get Verified</h2>
<p>
NIP-05 is a DNS based verification spec which helps to validate you as a
real user.
</p>
<p>Getting NIP-05 verified can help:</p>
<ul>
<li>Prevent fake accounts from imitating you</li>
<li>Make your profile easier to find and share</li>
<li>
Fund developers and platforms providing NIP-05 verification services
</li>
</ul>
{services.map(a => <Nip5Service key={a.name} {...a} />)}
</div>
)
}
{services.map((a) => (
<Nip5Service key={a.name} {...a} />
))}
</div>
);
}

View File

@ -4,23 +4,20 @@ import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
export default function DiscoverFollows() {
const navigate = useNavigate();
const navigate = useNavigate();
const sortedReccomends = useMemo(() => {
return RecommendedFollows
.sort(a => Math.random() >= 0.5 ? -1 : 1);
}, []);
const sortedReccomends = useMemo(() => {
return RecommendedFollows.sort((a) => (Math.random() >= 0.5 ? -1 : 1));
}, []);
return (
<>
<h2>Follow some popular accounts</h2>
<button onClick={() => navigate("/")}>
Skip
</button>
{sortedReccomends.length > 0 && (<FollowListBase pubkeys={sortedReccomends} />)}
<button onClick={() => navigate("/")}>
Done!
</button>
</>
)
}
return (
<>
<h2>Follow some popular accounts</h2>
<button onClick={() => navigate("/")}>Skip</button>
{sortedReccomends.length > 0 && (
<FollowListBase pubkeys={sortedReccomends} />
)}
<button onClick={() => navigate("/")}>Done!</button>
</>
);
}

View File

@ -11,57 +11,67 @@ import { useNavigate } from "react-router-dom";
const TwitterFollowsApi = `${ApiHost}/api/v1/twitter/follows-for-nostr`;
export default function ImportFollows() {
const navigate = useNavigate();
const currentFollows = useSelector((s: RootState) => s.login.follows);
const [twitterUsername, setTwitterUsername] = useState<string>("");
const [follows, setFollows] = useState<string[]>([]);
const [error, setError] = useState<string>("");
const navigate = useNavigate();
const currentFollows = useSelector((s: RootState) => s.login.follows);
const [twitterUsername, setTwitterUsername] = useState<string>("");
const [follows, setFollows] = useState<string[]>([]);
const [error, setError] = useState<string>("");
const sortedTwitterFollows = useMemo(() => {
return follows
.map((a) => bech32ToHex(a))
.sort((a, b) => (currentFollows.includes(a) ? 1 : -1));
}, [follows, currentFollows]);
const sortedTwitterFollows = useMemo(() => {
return follows.map(a => bech32ToHex(a))
.sort((a, b) => currentFollows.includes(a) ? 1 : -1);
}, [follows, currentFollows]);
async function loadFollows() {
setFollows([]);
setError("");
try {
let rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
let data = await rsp.json();
if (rsp.ok) {
if (Array.isArray(data) && data.length === 0) {
setError(`No nostr users found for "${twitterUsername}"`);
} else {
setFollows(data);
}
} else if ("error" in data) {
setError(data.error);
} else {
setError("Failed to load follows, please try again later");
}
} catch (e) {
console.warn(e);
setError("Failed to load follows, please try again later");
async function loadFollows() {
setFollows([]);
setError("");
try {
let rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
let data = await rsp.json();
if (rsp.ok) {
if (Array.isArray(data) && data.length === 0) {
setError(`No nostr users found for "${twitterUsername}"`);
} else {
setFollows(data);
}
} else if ("error" in data) {
setError(data.error);
} else {
setError("Failed to load follows, please try again later");
}
} catch (e) {
console.warn(e);
setError("Failed to load follows, please try again later");
}
}
return (
<>
<h2>Import Twitter Follows</h2>
<p>
Find your twitter follows on nostr (Data provided by <a href="https://nostr.directory" target="_blank" rel="noreferrer">nostr.directory</a>)
</p>
<div className="flex">
<input type="text" placeholder="Twitter username.." className="f-grow mr10" value={twitterUsername} onChange={e => setTwitterUsername(e.target.value)} />
<AsyncButton onClick={loadFollows}>Check</AsyncButton>
</div>
{error.length > 0 && <b className="error">{error}</b>}
{sortedTwitterFollows.length > 0 && (<FollowListBase pubkeys={sortedTwitterFollows} />)}
return (
<>
<h2>Import Twitter Follows</h2>
<p>
Find your twitter follows on nostr (Data provided by{" "}
<a href="https://nostr.directory" target="_blank" rel="noreferrer">
nostr.directory
</a>
)
</p>
<div className="flex">
<input
type="text"
placeholder="Twitter username.."
className="f-grow mr10"
value={twitterUsername}
onChange={(e) => setTwitterUsername(e.target.value)}
/>
<AsyncButton onClick={loadFollows}>Check</AsyncButton>
</div>
{error.length > 0 && <b className="error">{error}</b>}
{sortedTwitterFollows.length > 0 && (
<FollowListBase pubkeys={sortedTwitterFollows} />
)}
<button onClick={() => navigate("/new/discover")}>
Next
</button>
</>
)
}
<button onClick={() => navigate("/new/discover")}>Next</button>
</>
);
}

View File

@ -2,14 +2,12 @@ import ProfileSettings from "Pages/settings/Profile";
import { useNavigate } from "react-router-dom";
export default function NewUserProfile() {
const navigate = useNavigate();
return (
<>
<h1>Setup your Profile</h1>
<ProfileSettings privateKey={false} banner={false} />
<button onClick={() => navigate("/new/import")}>
Next
</button>
</>
)
}
const navigate = useNavigate();
return (
<>
<h1>Setup your Profile</h1>
<ProfileSettings privateKey={false} banner={false} />
<button onClick={() => navigate("/new/import")}>Next</button>
</>
);
}

View File

@ -9,78 +9,88 @@ import ImportFollows from "Pages/new/ImportFollows";
import DiscoverFollows from "Pages/new/DiscoverFollows";
export const NewUserRoutes: RouteObject[] = [
{
path: "/new",
element: <NewUserFlow />
},
{
path: "/new/profile",
element: <NewUserProfile />
},
{
path: "/new/import",
element: <ImportFollows />
},
{
path: "/new/discover",
element: <DiscoverFollows />
}
{
path: "/new",
element: <NewUserFlow />,
},
{
path: "/new/profile",
element: <NewUserProfile />,
},
{
path: "/new/import",
element: <ImportFollows />,
},
{
path: "/new/discover",
element: <DiscoverFollows />,
},
];
export default function NewUserFlow() {
const { privateKey } = useSelector((s: RootState) => s.login);
const navigate = useNavigate();
const { privateKey } = useSelector((s: RootState) => s.login);
const navigate = useNavigate();
return (
<>
<h1>Welcome to Snort!</h1>
<p>
Snort is a Nostr UI, nostr is a decentralised protocol for saving and distributing "notes".
</p>
<p>
Notes hold text content, the most popular usage of these notes is to store "tweet like" messages.
</p>
<p>
Snort is designed to have a similar experience to Twitter.
</p>
return (
<>
<h1>Welcome to Snort!</h1>
<p>
Snort is a Nostr UI, nostr is a decentralised protocol for saving and
distributing "notes".
</p>
<p>
Notes hold text content, the most popular usage of these notes is to
store "tweet like" messages.
</p>
<p>Snort is designed to have a similar experience to Twitter.</p>
<h2>Keys</h2>
<p>
Nostr uses digital signature technology to provide tamper proof notes which can safely
be replicated to many relays to provide redundant storage of your content.
</p>
<p>
This means that nobody can modify notes which you have created
and everybody can easily verify that the notes they are reading are created by you.
</p>
<p>
This is the same technology which is used by Bitcoin and has been proven to be extremely secure.
</p>
<h2>Keys</h2>
<p>
Nostr uses digital signature technology to provide tamper proof notes
which can safely be replicated to many relays to provide redundant
storage of your content.
</p>
<p>
This means that nobody can modify notes which you have created and
everybody can easily verify that the notes they are reading are created
by you.
</p>
<p>
This is the same technology which is used by Bitcoin and has been proven
to be extremely secure.
</p>
<h2>Your Key</h2>
<p>
When you want to author new notes you need to sign them with your private key,
as with Bitcoin private keys these need to be kept secure.
</p>
<p>
Please now copy your private key and save it somewhere secure:
</p>
<div className="card">
<Copy text={hexToBech32("nsec", privateKey ?? "")} />
</div>
<p>
It is also recommended to use one of the following browser extensions if you are on a desktop computer to secure your key:
</p>
<ul>
<li><a href="https://getalby.com/" target="_blank" rel="noreferrer">Alby</a></li>
<li><a href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noreferrer">nos2x</a></li>
</ul>
<p>
You can also use these extensions to login to most Nostr sites.
</p>
<button onClick={() => navigate("/new/profile")}>
Next
</button>
</>
)
}
<h2>Your Key</h2>
<p>
When you want to author new notes you need to sign them with your
private key, as with Bitcoin private keys these need to be kept secure.
</p>
<p>Please now copy your private key and save it somewhere secure:</p>
<div className="card">
<Copy text={hexToBech32("nsec", privateKey ?? "")} />
</div>
<p>
It is also recommended to use one of the following browser extensions if
you are on a desktop computer to secure your key:
</p>
<ul>
<li>
<a href="https://getalby.com/" target="_blank" rel="noreferrer">
Alby
</a>
</li>
<li>
<a
href="https://github.com/fiatjaf/nos2x"
target="_blank"
rel="noreferrer"
>
nos2x
</a>
</li>
</ul>
<p>You can also use these extensions to login to most Nostr sites.</p>
<button onClick={() => navigate("/new/profile")}>Next</button>
</>
);
}

View File

@ -1,5 +1,5 @@
.settings-nav .card {
cursor: pointer;
cursor: pointer;
}
.settings-row {
@ -9,7 +9,7 @@
font-weight: 600;
font-size: 16px;
line-height: 19px;
padding: 12px 16px;
padding: 12px 16px;
background: var(--note-bg);
border-radius: 10px;
cursor: pointer;

View File

@ -12,67 +12,65 @@ import Logout from "Icons/Logout";
import { logout } from "State/Login";
const SettingsIndex = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const dispatch = useDispatch();
const navigate = useNavigate();
function handleLogout() {
dispatch(logout())
navigate("/")
}
function handleLogout() {
dispatch(logout());
navigate("/");
}
return (
<>
<div className="settings-nav">
<div className="settings-row" onClick={() => navigate("profile")}>
<div className="mr10">
<Profile />
</div>
<span>
Profile
</span>
<div className="align-end">
<ArrowFront />
</div>
</div>
<div className="settings-row" onClick={() => navigate("relays")}>
<div className="mr10">
<Relay />
</div>
Relays
<div className="align-end">
<ArrowFront />
</div>
</div>
<div className="settings-row" onClick={() => navigate("preferences")}>
<div className="mr10">
<Gear />
</div>
Preferences
<div className="align-end">
<ArrowFront />
</div>
</div>
<div className="settings-row" onClick={() => navigate("/donate")}>
<div className="mr10">
<Heart />
</div>
Donate
<div className="align-end">
<ArrowFront />
</div>
</div>
<div className="settings-row" onClick={handleLogout}>
<div className="mr10">
<Logout />
</div>
Log Out
<div className="align-end">
<ArrowFront />
</div>
</div>
return (
<>
<div className="settings-nav">
<div className="settings-row" onClick={() => navigate("profile")}>
<div className="mr10">
<Profile />
</div>
<span>Profile</span>
<div className="align-end">
<ArrowFront />
</div>
</div>
</>
)
}
<div className="settings-row" onClick={() => navigate("relays")}>
<div className="mr10">
<Relay />
</div>
Relays
<div className="align-end">
<ArrowFront />
</div>
</div>
<div className="settings-row" onClick={() => navigate("preferences")}>
<div className="mr10">
<Gear />
</div>
Preferences
<div className="align-end">
<ArrowFront />
</div>
</div>
<div className="settings-row" onClick={() => navigate("/donate")}>
<div className="mr10">
<Heart />
</div>
Donate
<div className="align-end">
<ArrowFront />
</div>
</div>
<div className="settings-row" onClick={handleLogout}>
<div className="mr10">
<Logout />
</div>
Log Out
<div className="align-end">
<ArrowFront />
</div>
</div>
</div>
</>
);
};
export default SettingsIndex;

View File

@ -1,8 +1,8 @@
.preferences small {
margin-top: 0.5em;
color: var(--font-secondary-color);
margin-top: 0.5em;
color: var(--font-secondary-color);
}
.preferences select {
min-width: 100px;
}
min-width: 100px;
}

View File

@ -4,125 +4,254 @@ import { RootState } from "State/Store";
import "./Preferences.css";
const PreferencesPage = () => {
const dispatch = useDispatch();
const perf = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const dispatch = useDispatch();
const perf = useSelector<RootState, UserPreferences>(
(s) => s.login.preferences
);
return (
<div className="preferences">
<h3>Preferences</h3>
return (
<div className="preferences">
<h3>Preferences</h3>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Theme</div>
</div>
<div>
<select value={perf.theme} onChange={e => dispatch(setPreferences({ ...perf, theme: e.target.value } as UserPreferences))}>
<option value="system">System (Default)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Automatically load media</div>
<small>Media in posts will automatically be shown for selected people, otherwise only the link will show</small>
</div>
<div>
<select value={perf.autoLoadMedia} onChange={e => dispatch(setPreferences({ ...perf, autoLoadMedia: e.target.value } as UserPreferences))}>
<option value="none">None</option>
<option value="follows-only">Follows only</option>
<option value="all">All</option>
</select>
</div>
</div>
<div className="card flex f-col">
<div className="flex w-max">
<div className="flex f-col f-grow">
<div>Image proxy service</div>
<small>Use imgproxy to compress images</small>
</div>
<div>
<input type="checkbox" checked={perf.imgProxyConfig !== null} onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: e.target.checked ? DefaultImgProxy : null }))} />
</div>
</div>
{perf.imgProxyConfig && (<div className="w-max mt10 form">
<div className="form-group">
<div>
Service Url
</div>
<div className="w-max">
<input type="text" value={perf.imgProxyConfig?.url} placeholder="Url.." onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: { ...perf.imgProxyConfig!, url: e.target.value } }))} />
</div>
</div>
<div className="form-group">
<div>
Service Key
</div>
<div className="w-max">
<input type="password" value={perf.imgProxyConfig?.key} placeholder="Hex key.." onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: { ...perf.imgProxyConfig!, key: e.target.value } }))} />
</div>
</div>
<div className="form-group">
<div>
Service Salt
</div>
<div className="w-max">
<input type="password" value={perf.imgProxyConfig?.salt} placeholder="Hex salt.." onChange={e => dispatch(setPreferences({ ...perf, imgProxyConfig: { ...perf.imgProxyConfig!, salt: e.target.value } }))} />
</div>
</div>
</div>)}
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Enable reactions</div>
<small>Reactions will be shown on every page, if disabled no reactions will be shown</small>
</div>
<div>
<input type="checkbox" checked={perf.enableReactions} onChange={e => dispatch(setPreferences({ ...perf, enableReactions: e.target.checked }))} />
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Confirm reposts</div>
<small>Reposts need to be manually confirmed</small>
</div>
<div>
<input type="checkbox" checked={perf.confirmReposts} onChange={e => dispatch(setPreferences({ ...perf, confirmReposts: e.target.checked }))} />
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Automatically show latest notes</div>
<small>Notes will stream in real time into global and posts tab</small>
</div>
<div>
<input type="checkbox" checked={perf.autoShowLatest} onChange={e => dispatch(setPreferences({ ...perf, autoShowLatest: e.target.checked }))} />
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>File upload service</div>
<small>Pick which upload service you want to upload attachments to</small>
</div>
<div>
<select value={perf.fileUploader} onChange={e => dispatch(setPreferences({ ...perf, fileUploader: e.target.value } as UserPreferences))}>
<option value="void.cat">void.cat (Default)</option>
<option value="nostr.build">nostr.build</option>
<option value="nostrimg.com">nostrimg.com</option>
</select>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Debug Menus</div>
<small>Shows "Copy ID" and "Copy Event JSON" in the context menu on each message</small>
</div>
<div>
<input type="checkbox" checked={perf.showDebugMenus} onChange={e => dispatch(setPreferences({ ...perf, showDebugMenus: e.target.checked }))} />
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Theme</div>
</div>
)
}
export default PreferencesPage;
<div>
<select
value={perf.theme}
onChange={(e) =>
dispatch(
setPreferences({
...perf,
theme: e.target.value,
} as UserPreferences)
)
}
>
<option value="system">System (Default)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Automatically load media</div>
<small>
Media in posts will automatically be shown for selected people,
otherwise only the link will show
</small>
</div>
<div>
<select
value={perf.autoLoadMedia}
onChange={(e) =>
dispatch(
setPreferences({
...perf,
autoLoadMedia: e.target.value,
} as UserPreferences)
)
}
>
<option value="none">None</option>
<option value="follows-only">Follows only</option>
<option value="all">All</option>
</select>
</div>
</div>
<div className="card flex f-col">
<div className="flex w-max">
<div className="flex f-col f-grow">
<div>Image proxy service</div>
<small>Use imgproxy to compress images</small>
</div>
<div>
<input
type="checkbox"
checked={perf.imgProxyConfig !== null}
onChange={(e) =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: e.target.checked ? DefaultImgProxy : null,
})
)
}
/>
</div>
</div>
{perf.imgProxyConfig && (
<div className="w-max mt10 form">
<div className="form-group">
<div>Service Url</div>
<div className="w-max">
<input
type="text"
value={perf.imgProxyConfig?.url}
placeholder="Url.."
onChange={(e) =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: {
...perf.imgProxyConfig!,
url: e.target.value,
},
})
)
}
/>
</div>
</div>
<div className="form-group">
<div>Service Key</div>
<div className="w-max">
<input
type="password"
value={perf.imgProxyConfig?.key}
placeholder="Hex key.."
onChange={(e) =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: {
...perf.imgProxyConfig!,
key: e.target.value,
},
})
)
}
/>
</div>
</div>
<div className="form-group">
<div>Service Salt</div>
<div className="w-max">
<input
type="password"
value={perf.imgProxyConfig?.salt}
placeholder="Hex salt.."
onChange={(e) =>
dispatch(
setPreferences({
...perf,
imgProxyConfig: {
...perf.imgProxyConfig!,
salt: e.target.value,
},
})
)
}
/>
</div>
</div>
</div>
)}
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Enable reactions</div>
<small>
Reactions will be shown on every page, if disabled no reactions will
be shown
</small>
</div>
<div>
<input
type="checkbox"
checked={perf.enableReactions}
onChange={(e) =>
dispatch(
setPreferences({ ...perf, enableReactions: e.target.checked })
)
}
/>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Confirm reposts</div>
<small>Reposts need to be manually confirmed</small>
</div>
<div>
<input
type="checkbox"
checked={perf.confirmReposts}
onChange={(e) =>
dispatch(
setPreferences({ ...perf, confirmReposts: e.target.checked })
)
}
/>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Automatically show latest notes</div>
<small>
Notes will stream in real time into global and posts tab
</small>
</div>
<div>
<input
type="checkbox"
checked={perf.autoShowLatest}
onChange={(e) =>
dispatch(
setPreferences({ ...perf, autoShowLatest: e.target.checked })
)
}
/>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>File upload service</div>
<small>
Pick which upload service you want to upload attachments to
</small>
</div>
<div>
<select
value={perf.fileUploader}
onChange={(e) =>
dispatch(
setPreferences({
...perf,
fileUploader: e.target.value,
} as UserPreferences)
)
}
>
<option value="void.cat">void.cat (Default)</option>
<option value="nostr.build">nostr.build</option>
<option value="nostrimg.com">nostrimg.com</option>
</select>
</div>
</div>
<div className="card flex">
<div className="flex f-col f-grow">
<div>Debug Menus</div>
<small>
Shows "Copy ID" and "Copy Event JSON" in the context menu on each
message
</small>
</div>
<div>
<input
type="checkbox"
checked={perf.showDebugMenus}
onChange={(e) =>
dispatch(
setPreferences({ ...perf, showDebugMenus: e.target.checked })
)
}
/>
</div>
</div>
</div>
);
};
export default PreferencesPage;

View File

@ -1,43 +1,44 @@
.settings .avatar {
width: 256px;
height: 256px;
background-size: cover;
border-radius: 100%;
cursor: pointer;
margin-bottom: 20px;
width: 256px;
height: 256px;
background-size: cover;
border-radius: 100%;
cursor: pointer;
margin-bottom: 20px;
}
.settings .banner {
width: 300px;
height: 150px;
background-size: cover;
margin-bottom: 20px;
width: 300px;
height: 150px;
background-size: cover;
margin-bottom: 20px;
}
.settings .image-settings {
flex-direction: column;
align-items: center;
justify-content: center;
flex-direction: column;
align-items: center;
justify-content: center;
}
.settings .avatar .edit, .settings .banner .edit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
opacity: 0;
background-color: var(--bg-color);
.settings .avatar .edit,
.settings .banner .edit {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
opacity: 0;
background-color: var(--bg-color);
}
.settings .avatar .edit:hover {
opacity: 0.5;
opacity: 0.5;
}
.settings .editor textarea {
resize: vertical;
max-height: 300px;
min-height: 40px;
resize: vertical;
max-height: 300px;
min-height: 40px;
}
.settings .actions {

View File

@ -16,184 +16,233 @@ import { HexKey } from "Nostr";
import useFileUpload from "Upload";
export interface ProfileSettingsProps {
avatar?: boolean,
banner?: boolean,
privateKey?: boolean
avatar?: boolean;
banner?: boolean;
privateKey?: boolean;
}
export default function ProfileSettings(props: ProfileSettingsProps) {
const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const user = useUserProfile(id!);
const publisher = useEventPublisher();
const uploader = useFileUpload();
const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(
(s) => s.login.publicKey
);
const privKey = useSelector<RootState, HexKey | undefined>(
(s) => s.login.privateKey
);
const user = useUserProfile(id!);
const publisher = useEventPublisher();
const uploader = useFileUpload();
const [name, setName] = useState<string>();
const [displayName, setDisplayName] = useState<string>();
const [picture, setPicture] = useState<string>();
const [banner, setBanner] = useState<string>();
const [about, setAbout] = useState<string>();
const [website, setWebsite] = useState<string>();
const [nip05, setNip05] = useState<string>();
const [lud16, setLud16] = useState<string>();
const [name, setName] = useState<string>();
const [displayName, setDisplayName] = useState<string>();
const [picture, setPicture] = useState<string>();
const [banner, setBanner] = useState<string>();
const [about, setAbout] = useState<string>();
const [website, setWebsite] = useState<string>();
const [nip05, setNip05] = useState<string>();
const [lud16, setLud16] = useState<string>();
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture;
useEffect(() => {
if (user) {
setName(user.name);
setDisplayName(user.display_name)
setPicture(user.picture);
setBanner(user.banner);
setAbout(user.about);
setWebsite(user.website);
setNip05(user.nip05);
setLud16(user.lud16);
}
}, [user]);
async function saveProfile() {
// copy user object and delete internal fields
let userCopy = {
...user,
name,
display_name: displayName,
about,
picture,
banner,
website,
nip05,
lud16
};
delete userCopy["loaded"];
delete userCopy["created"];
delete userCopy["pubkey"];
delete userCopy["npub"];
console.debug(userCopy);
let ev = await publisher.metadata(userCopy);
console.debug(ev);
publisher.broadcast(ev);
useEffect(() => {
if (user) {
setName(user.name);
setDisplayName(user.display_name);
setPicture(user.picture);
setBanner(user.banner);
setAbout(user.about);
setWebsite(user.website);
setNip05(user.nip05);
setLud16(user.lud16);
}
}, [user]);
async function uploadFile() {
let file = await openFile();
if (file) {
console.log(file);
let rsp = await uploader.upload(file, file.name);
console.log(rsp);
if (typeof rsp?.error === "string") {
throw new Error(`Upload failed ${rsp.error}`);
}
return rsp.url;
}
async function saveProfile() {
// copy user object and delete internal fields
let userCopy = {
...user,
name,
display_name: displayName,
about,
picture,
banner,
website,
nip05,
lud16,
};
delete userCopy["loaded"];
delete userCopy["created"];
delete userCopy["pubkey"];
delete userCopy["npub"];
console.debug(userCopy);
let ev = await publisher.metadata(userCopy);
console.debug(ev);
publisher.broadcast(ev);
}
async function uploadFile() {
let file = await openFile();
if (file) {
console.log(file);
let rsp = await uploader.upload(file, file.name);
console.log(rsp);
if (typeof rsp?.error === "string") {
throw new Error(`Upload failed ${rsp.error}`);
}
return rsp.url;
}
}
async function setNewAvatar() {
const rsp = await uploadFile();
if (rsp) {
setPicture(rsp);
}
async function setNewAvatar() {
const rsp = await uploadFile();
if (rsp) {
setPicture(rsp);
}
}
async function setNewBanner() {
const rsp = await uploadFile();
if (rsp) {
setBanner(rsp);
}
}
function editor() {
return (
<div className="editor form">
<div className="form-group">
<div>Name:</div>
<div>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} />
</div>
</div>
<div className="form-group">
<div>Display name:</div>
<div>
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div>
</div>
<div className="form-group form-col">
<div>About:</div>
<div className="w-max">
<textarea className="w-max" onChange={(e) => setAbout(e.target.value)} value={about}></textarea>
</div>
</div>
<div className="form-group">
<div>Website:</div>
<div>
<input type="text" value={website} onChange={(e) => setWebsite(e.target.value)} />
</div>
</div>
<div className="form-group">
<div>NIP-05:</div>
<div>
<input type="text" className="mr10" value={nip05} onChange={(e) => setNip05(e.target.value)} />
<button type="button" onClick={() => navigate("/verification")}>
<FontAwesomeIcon icon={faShop} />
&nbsp;
Buy
</button>
</div>
</div>
<div className="form-group">
<div>LN Address:</div>
<div>
<input type="text" value={lud16} onChange={(e) => setLud16(e.target.value)} />
</div>
</div>
<div className="form-group">
<div>
</div>
<div>
<button type="button" onClick={() => saveProfile()}>Save</button>
</div>
</div>
</div>
)
}
function settings() {
if (!id) return null;
return (
<>
<div className="flex f-center image-settings">
{(props.avatar ?? true) && (<div>
<h2>Avatar</h2>
<div style={{ backgroundImage: `url(${avatarPicture})` }} className="avatar">
<div className="edit" onClick={() => setNewAvatar()}>Edit</div>
</div>
</div>)}
{(props.banner ?? true) && (<div>
<h2>Header</h2>
<div style={{ backgroundImage: `url(${(banner?.length ?? 0) === 0 ? Nostrich : banner})` }} className="banner">
<div className="edit" onClick={() => setNewBanner()}>Edit</div>
</div>
</div>)}
</div>
{editor()}
</>
)
async function setNewBanner() {
const rsp = await uploadFile();
if (rsp) {
setBanner(rsp);
}
}
function editor() {
return (
<div className="settings">
<h3>Edit Profile</h3>
{settings()}
{privKey && (props.privateKey ?? true) && (<div className="flex f-col bg-grey">
<div>
<h4>Your Private Key Is (do not share this with anyone):</h4>
</div>
<div>
<Copy text={hexToBech32("nsec", privKey)} />
</div>
</div>)}
<div className="editor form">
<div className="form-group">
<div>Name:</div>
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className="form-group">
<div>Display name:</div>
<div>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
</div>
<div className="form-group form-col">
<div>About:</div>
<div className="w-max">
<textarea
className="w-max"
onChange={(e) => setAbout(e.target.value)}
value={about}
></textarea>
</div>
</div>
<div className="form-group">
<div>Website:</div>
<div>
<input
type="text"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
</div>
<div className="form-group">
<div>NIP-05:</div>
<div>
<input
type="text"
className="mr10"
value={nip05}
onChange={(e) => setNip05(e.target.value)}
/>
<button type="button" onClick={() => navigate("/verification")}>
<FontAwesomeIcon icon={faShop} />
&nbsp; Buy
</button>
</div>
</div>
<div className="form-group">
<div>LN Address:</div>
<div>
<input
type="text"
value={lud16}
onChange={(e) => setLud16(e.target.value)}
/>
</div>
</div>
<div className="form-group">
<div></div>
<div>
<button type="button" onClick={() => saveProfile()}>
Save
</button>
</div>
</div>
</div>
);
}
function settings() {
if (!id) return null;
return (
<>
<div className="flex f-center image-settings">
{(props.avatar ?? true) && (
<div>
<h2>Avatar</h2>
<div
style={{ backgroundImage: `url(${avatarPicture})` }}
className="avatar"
>
<div className="edit" onClick={() => setNewAvatar()}>
Edit
</div>
</div>
</div>
)}
{(props.banner ?? true) && (
<div>
<h2>Header</h2>
<div
style={{
backgroundImage: `url(${
(banner?.length ?? 0) === 0 ? Nostrich : banner
})`,
}}
className="banner"
>
<div className="edit" onClick={() => setNewBanner()}>
Edit
</div>
</div>
</div>
)}
</div>
{editor()}
</>
);
}
return (
<div className="settings">
<h3>Edit Profile</h3>
{settings()}
{privKey && (props.privateKey ?? true) && (
<div className="flex f-col bg-grey">
<div>
<h4>Your Private Key Is (do not share this with anyone):</h4>
</div>
<div>
<Copy text={hexToBech32("nsec", privKey)} />
</div>
</div>
)}
</div>
);
}

View File

@ -7,51 +7,98 @@ import { removeRelay } from "State/Login";
import { parseId } from "Util";
const RelayInfo = () => {
const params = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const params = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const conn = Array.from(System.Sockets.values()).find(a => a.Id === params.id);
console.debug(conn);
const stats = useRelayState(conn?.Address ?? "");
const conn = Array.from(System.Sockets.values()).find(
(a) => a.Id === params.id
);
console.debug(conn);
const stats = useRelayState(conn?.Address ?? "");
return (
<>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}>Relays</h3>
<div className="card">
<h3>{stats?.info?.name}</h3>
<p>{stats?.info?.description}</p>
return (
<>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}>
Relays
</h3>
<div className="card">
<h3>{stats?.info?.name}</h3>
<p>{stats?.info?.description}</p>
{stats?.info?.pubkey && (<>
<h4>Owner</h4>
<ProfilePreview pubkey={parseId(stats.info.pubkey)} />
</>)}
{stats?.info?.software && (<div className="flex">
<h4 className="f-grow">Software</h4>
<div className="flex f-col">
{stats.info.software.startsWith("http") ? <a href={stats.info.software} target="_blank" rel="noreferrer">{stats.info.software}</a> : <>{stats.info.software}</>}
<small>{!stats.info.version?.startsWith("v") && "v"}{stats.info.version}</small>
</div>
</div>)}
{stats?.info?.contact && (<div className="flex">
<h4 className="f-grow">Contact</h4>
<a href={`${stats.info.contact.startsWith("mailto:") ? "" : "mailto:"}${stats.info.contact}`} target="_blank" rel="noreferrer">{stats.info.contact}</a>
</div>)}
{stats?.info?.supported_nips && (<>
<h4>Supports</h4>
<div className="f-grow">
{stats.info.supported_nips.map(a => <span className="pill" onClick={() => navigate(`https://github.com/nostr-protocol/nips/blob/master/${a.toString().padStart(2, "0")}.md`)}>NIP-{a.toString().padStart(2, "0")}</span>)}
</div>
</>)}
<div className="flex mt10 f-end">
<div className="btn error" onClick={() => {
dispatch(removeRelay(conn!.Address));
navigate("/settings/relays")
}}>Remove</div>
</div>
{stats?.info?.pubkey && (
<>
<h4>Owner</h4>
<ProfilePreview pubkey={parseId(stats.info.pubkey)} />
</>
)}
{stats?.info?.software && (
<div className="flex">
<h4 className="f-grow">Software</h4>
<div className="flex f-col">
{stats.info.software.startsWith("http") ? (
<a href={stats.info.software} target="_blank" rel="noreferrer">
{stats.info.software}
</a>
) : (
<>{stats.info.software}</>
)}
<small>
{!stats.info.version?.startsWith("v") && "v"}
{stats.info.version}
</small>
</div>
</>
)
}
</div>
)}
{stats?.info?.contact && (
<div className="flex">
<h4 className="f-grow">Contact</h4>
<a
href={`${
stats.info.contact.startsWith("mailto:") ? "" : "mailto:"
}${stats.info.contact}`}
target="_blank"
rel="noreferrer"
>
{stats.info.contact}
</a>
</div>
)}
{stats?.info?.supported_nips && (
<>
<h4>Supports</h4>
<div className="f-grow">
{stats.info.supported_nips.map((a) => (
<span
className="pill"
onClick={() =>
navigate(
`https://github.com/nostr-protocol/nips/blob/master/${a
.toString()
.padStart(2, "0")}.md`
)
}
>
NIP-{a.toString().padStart(2, "0")}
</span>
))}
</div>
</>
)}
<div className="flex mt10 f-end">
<div
className="btn error"
onClick={() => {
dispatch(removeRelay(conn!.Address));
navigate("/settings/relays");
}}
>
Remove
</div>
</div>
</div>
</>
);
};
export default RelayInfo;
export default RelayInfo;

View File

@ -8,57 +8,70 @@ import { RelaySettings } from "Nostr/Connection";
import { setRelays } from "State/Login";
const RelaySettingsPage = () => {
const dispatch = useDispatch();
const publisher = useEventPublisher();
const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const [newRelay, setNewRelay] = useState<string>();
const dispatch = useDispatch();
const publisher = useEventPublisher();
const relays = useSelector<RootState, Record<string, RelaySettings>>(
(s) => s.login.relays
);
const [newRelay, setNewRelay] = useState<string>();
async function saveRelays() {
let ev = await publisher.saveRelays();
publisher.broadcast(ev);
publisher.broadcastForBootstrap(ev);
}
function addRelay() {
return (
<>
<h4>Add Relays</h4>
<div className="flex mb10">
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
</div>
<button className="secondary mb10" onClick={() => addNewRelay()}>Add</button>
</>
)
}
function addNewRelay() {
if ((newRelay?.length ?? 0) > 0) {
const parsed = new URL(newRelay!);
const payload = {
relays: {
...relays,
[parsed.toString()]: { read: false, write: false }
},
createdAt: Math.floor(new Date().getTime() / 1000)
};
dispatch(setRelays(payload))
}
}
async function saveRelays() {
let ev = await publisher.saveRelays();
publisher.broadcast(ev);
publisher.broadcastForBootstrap(ev);
}
function addRelay() {
return (
<>
<h3>Relays</h3>
<div className="flex f-col mb10">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div>
<div className="flex mt10">
<div className="f-grow"></div>
<button type="button" onClick={() => saveRelays()}>Save</button>
</div>
{addRelay()}
</>
)
}
<>
<h4>Add Relays</h4>
<div className="flex mb10">
<input
type="text"
className="f-grow"
placeholder="wss://my-relay.com"
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
/>
</div>
<button className="secondary mb10" onClick={() => addNewRelay()}>
Add
</button>
</>
);
}
function addNewRelay() {
if ((newRelay?.length ?? 0) > 0) {
const parsed = new URL(newRelay!);
const payload = {
relays: {
...relays,
[parsed.toString()]: { read: false, write: false },
},
createdAt: Math.floor(new Date().getTime() / 1000),
};
dispatch(setRelays(payload));
}
}
return (
<>
<h3>Relays</h3>
<div className="flex f-col mb10">
{Object.keys(relays || {}).map((a) => (
<Relay addr={a} key={a} />
))}
</div>
<div className="flex mt10">
<div className="f-grow"></div>
<button type="button" onClick={() => saveRelays()}>
Save
</button>
</div>
{addRelay()}
</>
);
};
export default RelaySettingsPage;