feat: user preferences

This commit is contained in:
Kieran 2023-01-20 17:07:14 +00:00
parent 57136afda5
commit 5eb0623fb8
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
17 changed files with 715 additions and 701 deletions

View File

@ -13,148 +13,150 @@ import { default as NEvent } from "Nostr/Event";
import { RootState } from "State/Store";
import { HexKey, TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind";
import { UserPreferences } from "State/Login";
export interface NoteFooterProps {
related: TaggedRawEvent[],
ev: NEvent
related: TaggedRawEvent[],
ev: NEvent
}
export default function NoteFooter(props: NoteFooterProps) {
const { related, ev } = props;
const { related, ev } = props;
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login;
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related]);
const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(content);
const amount = acc[r] || 0
return { ...acc, [r]: amount + 1 }
}, {
[Reaction.Positive]: 0,
[Reaction.Negative]: 0
});
}, [reactions]);
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
const publisher = useEventPublisher();
const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login;
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related]);
const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(content);
const amount = acc[r] || 0
return { ...acc, [r]: amount + 1 }
}, {
[Reaction.Positive]: 0,
[Reaction.Negative]: 0
});
}, [reactions]);
function hasReacted(emoji: string) {
return reactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login)
function hasReacted(emoji: string) {
return reactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login)
}
function hasReposted() {
return reposts.some(a => a.pubkey === login);
}
async function react(content: string) {
if (!hasReacted(content)) {
let evLike = await publisher.react(ev, content);
publisher.broadcast(evLike);
}
}
function hasReposted() {
return reposts.some(a => a.pubkey === login);
async function deleteEvent() {
if (window.confirm(`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`)) {
let evDelete = await publisher.delete(ev.Id);
publisher.broadcast(evDelete);
}
}
async function react(content: string) {
if (!hasReacted(content)) {
let evLike = await publisher.react(ev, content);
publisher.broadcast(evLike);
async function repost() {
if (!hasReposted()) {
if (!prefs.confirmReposts || window.confirm(`Are you sure you want to repost: ${ev.Id}`)) {
let evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
}
}
}
async function deleteEvent() {
if (window.confirm(`Are you sure you want to delete ${ev.Id.substring(0, 8)}?`)) {
let evDelete = await publisher.delete(ev.Id);
publisher.broadcast(evDelete);
}
}
async function repost() {
if (!hasReposted()) {
let evRepost = await publisher.repost(ev);
publisher.broadcast(evRepost);
}
}
function tipButton() {
let service = author?.lud16 || author?.lud06;
if (service) {
return (
<>
<div className="reaction-pill" onClick={(e) => setTip(true)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faBolt} />
</div>
</div>
</>
)
}
return null;
}
function reactionIcon(content: string, reacted: boolean) {
switch (content) {
case Reaction.Positive: {
return <FontAwesomeIcon icon={faHeart} />;
}
case Reaction.Negative: {
return <FontAwesomeIcon icon={faThumbsDown} />;
}
}
return content;
}
function repostIcon() {
function tipButton() {
let service = author?.lud16 || author?.lud06;
if (service) {
return (
<div className={`reaction-pill ${hasReposted() ? 'reacted' : ''}`} onClick={() => repost()}>
<>
<div className="reaction-pill" onClick={(e) => setTip(true)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faRepeat} />
<FontAwesomeIcon icon={faBolt} />
</div>
{reposts.length > 0 && (
<div className="reaction-pill-number">
{formatShort(reposts.length)}
</div>
)}
</div>
</>
)
}
return null;
}
function repostIcon() {
return (
<>
<div className="footer">
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faReply} />
</div>
</div>
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={(e) => react("+")}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faHeart} />
</div>
<div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Positive])}
</div>
</div>
<div className={`reaction-pill ${hasReacted('-') ? 'reacted' : ''}`} onClick={(e) => react("-")}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faThumbsDown} />
</div>
<div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Negative])}
</div>
</div>
{repostIcon()}
{tipButton()}
{isMine && (
<div className="reaction-pill trash-icon">
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faTrash} onClick={(e) => deleteEvent()} />
</div>
</div>
)}
</div>
<NoteCreator
autoFocus={true}
replyTo={ev}
onSend={() => setReply(false)}
show={reply}
/>
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
</>
<div className={`reaction-pill ${hasReposted() ? 'reacted' : ''}`} onClick={() => repost()}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faRepeat} />
</div>
{reposts.length > 0 && (
<div className="reaction-pill-number">
{formatShort(reposts.length)}
</div>
)}
</div>
)
}
function reactionIcons() {
if (!prefs.enableReactions) {
return null;
}
return (
<>
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={(e) => react("+")}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faHeart} />
</div>
<div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Positive])}
</div>
</div>
<div className={`reaction-pill ${hasReacted('-') ? 'reacted' : ''}`} onClick={(e) => react("-")}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faThumbsDown} />
</div>
<div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Negative])}
</div>
</div>
{repostIcon()}
</>
)
}
return (
<>
<div className="footer">
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faReply} />
</div>
</div>
{reactionIcons()}
{tipButton()}
{isMine && (
<div className="reaction-pill trash-icon">
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faTrash} onClick={(e) => deleteEvent()} />
</div>
</div>
)}
</div>
<NoteCreator
autoFocus={true}
replyTo={ev}
onSend={() => setReply(false)}
show={reply}
/>
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
</>
)
}

View File

@ -13,9 +13,15 @@ import Tag from "Nostr/Tag";
import { MetadataCache } from "Db/User";
import Mention from "Element/Mention";
import TidalEmbed from "Element/TidalEmbed";
import { useSelector } from 'react-redux';
import { RootState } from 'State/Store';
import { UserPreferences } from 'State/Login';
function transformHttpLink(a: string) {
function transformHttpLink(a: string, pref: UserPreferences) {
try {
if (!pref.autoLoadMedia) {
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
}
const url = new URL(a);
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
@ -73,12 +79,12 @@ function transformHttpLink(a: string) {
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
}
function extractLinks(fragments: Fragment[]) {
function extractLinks(fragments: Fragment[], pref: UserPreferences) {
return fragments.map(f => {
if (typeof f === "string") {
return f.split(UrlRegex).map(a => {
if (a.startsWith("http")) {
return transformHttpLink(a)
return transformHttpLink(a, pref)
}
return a;
});
@ -87,14 +93,14 @@ function extractLinks(fragments: Fragment[]) {
}).flat();
}
function extractMentions(fragments: Fragment[], tags: Tag[], users: Map<string, MetadataCache>) {
return fragments.map(f => {
function extractMentions(frag: TextFragment) {
return frag.body.map(f => {
if (typeof f === "string") {
return f.split(MentionRegex).map((match) => {
let matchTag = match.match(/#\[(\d+)\]/);
if (matchTag && matchTag.length === 2) {
let idx = parseInt(matchTag[1]);
let ref = tags?.find(a => a.Index === idx);
let ref = frag.tags?.find(a => a.Index === idx);
if (ref) {
switch (ref.Key) {
case "p": {
@ -149,25 +155,25 @@ function extractHashtags(fragments: Fragment[]) {
}).flat();
}
function transformLi({ body, tags, users }: TextFragment) {
let fragments = transformText({ body, tags, users })
function transformLi(frag: TextFragment) {
let fragments = transformText(frag)
return <li>{fragments}</li>
}
function transformParagraph({ body, tags, users }: TextFragment) {
const fragments = transformText({ body, tags, users })
function transformParagraph(frag: TextFragment) {
const fragments = transformText(frag)
if (fragments.every(f => typeof f === 'string')) {
return <p>{fragments}</p>
}
return <>{fragments}</>
}
function transformText({ body, tags, users }: TextFragment) {
if (body === undefined) {
function transformText(frag: TextFragment) {
if (frag.body === undefined) {
debugger;
}
let fragments = extractMentions(body, tags, users);
fragments = extractLinks(fragments);
let fragments = extractMentions(frag);
fragments = extractLinks(fragments, frag.pref);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
return fragments;
@ -178,7 +184,8 @@ export type Fragment = string | JSX.Element;
export interface TextFragment {
body: Fragment[],
tags: Tag[],
users: Map<string, MetadataCache>
users: Map<string, MetadataCache>,
pref: UserPreferences
}
export interface TextProps {
@ -188,11 +195,12 @@ export interface TextProps {
}
export default function Text({ content, tags, users }: TextProps) {
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const components = useMemo(() => {
return {
p: (x: any) => transformParagraph({ body: x.children ?? [], tags, users }),
a: (x: any) => transformHttpLink(x.href),
li: (x: any) => transformLi({ body: x.children ?? [], tags, users }),
p: (x: any) => transformParagraph({ body: x.children ?? [], tags, users, pref }),
a: (x: any) => transformHttpLink(x.href, pref),
li: (x: any) => transformLi({ body: x.children ?? [], tags, users, pref }),
};
}, [content]);
return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown>

View File

@ -3,9 +3,13 @@ import { u256 } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription from "Feed/Subscription";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
export default function useThreadFeed(id: u256) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
function addId(id: u256[]) {
setTrackingEvent((s) => {
@ -21,7 +25,7 @@ export default function useThreadFeed(id: u256) {
// get replies to this event
const subRelated = new Subscriptions();
subRelated.Kinds = new Set([EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost]);
subRelated.Kinds = new Set(pref.enableReactions ? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost] : [EventKind.TextNote]);
subRelated.ETags = thisSub.Ids;
thisSub.AddSubscription(subRelated);

View File

@ -4,6 +4,9 @@ import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import { unixNow } from "Util";
import useSubscription from "Feed/Subscription";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { UserPreferences } from "State/Login";
export interface TimelineFeedOptions {
method: "TIME_RANGE" | "LIMIT_UNTIL"
@ -20,6 +23,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const sub = useMemo(() => {
if (subject.type !== "global" && subject.items.length == 0) {
@ -56,7 +60,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const main = useSubscription(sub, { leaveOpen: true });
const subNext = useMemo(() => {
if (trackingEvents.length > 0) {
if (trackingEvents.length > 0 && pref.enableReactions) {
let sub = new Subscriptions();
sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.Repost]);

View File

@ -1,9 +1,15 @@
import ProfilePreview from "Element/ProfilePreview";
import ZapButton from "Element/ZapButton";
import { bech32ToHex } from "Util";
const Developers = [
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // kieran
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194" // verbiricha
bech32ToHex("npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49"), // kieran
bech32ToHex("npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg") // verbiricha
];
const Contributors = [
bech32ToHex("npub10djxr5pvdu97rjkde7tgcsjxzpdzmdguwacfjwlchvj7t88dl7nsdl54nf"), // ivan
bech32ToHex("npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"), // liran cohen
];
const DonatePage = () => {
@ -19,8 +25,10 @@ const DonatePage = () => {
<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>
<h3>Developers</h3>
<h3>Primary Developers</h3>
{Developers.map(a => <ProfilePreview pubkey={a} key={a} actions={<ZapButton pubkey={a} />} />)}
<h4>Contributors</h4>
{Contributors.map(a => <ProfilePreview pubkey={a} key={a} actions={<ZapButton pubkey={a} />} />)}
</div>
);
}

View File

@ -6,7 +6,7 @@ import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { RootState } from "State/Store";
import { init } from "State/Login";
import { init, UserPreferences } from "State/Login";
import { HexKey, RawEvent, TaggedRawEvent } from "Nostr";
import { RelaySettings } from "Nostr/Connection";
import { System } from "Nostr/System"
@ -23,6 +23,7 @@ export default function Layout() {
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
const readNotifications = useSelector<RootState, number>(s => s.login.readNotifications);
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
useLoginFeed();
useEffect(() => {
@ -38,6 +39,15 @@ export default function Layout() {
}
}, [relays]);
useEffect(() => {
const elm = document.documentElement;
if (prefs.theme === "light") {
elm.classList.add("light");
} else {
elm.classList.remove("light");
}
}, [prefs]);
useEffect(() => {
dispatch(init());
}, []);

View File

@ -1,240 +1,35 @@
import "./SettingsPage.css";
import Nostrich from "nostrich.jpg";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShop } from "@fortawesome/free-solid-svg-icons";
import { RootState } from "State/Store";
import { logout, setRelays } from "State/Login";
import useEventPublisher from "Feed/EventPublisher";
import useProfile from "Feed/ProfileFeed";
import VoidUpload from "Feed/VoidUpload";
import { hexToBech32, openFile } from "Util";
import Relay from "Element/Relay";
import Copy from "Element/Copy";
import { HexKey, UserMetadata } from "Nostr";
import { RelaySettings } from "Nostr/Connection";
import { MetadataCache } from "Db/User";
import { Outlet, RouteObject, useNavigate } from "react-router-dom";
import SettingsIndex from "Pages/settings/Index";
import Profile from "Pages/settings/Profile";
import Relay from "Pages/settings/Relays";
import Preferences from "Pages/settings/Preferences";
export default function SettingsPage() {
const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const dispatch = useDispatch();
const user = useProfile(id)?.get(id || "");
const publisher = useEventPublisher();
const [name, setName] = useState<string>();
const [displayName, setDisplayName] = useState<string>();
const [picture, setPicture] = useState<string>();
const [banner, setBanner] = useState<string>();
const [about, setAbout] = useState<string>();
const [website, setWebsite] = useState<string>();
const [nip05, setNip05] = useState<string>();
const [lud06, setLud06] = useState<string>();
const [lud16, setLud16] = useState<string>();
const [newRelay, setNewRelay] = useState<string>();
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
useEffect(() => {
if (user) {
setName(user.name);
setDisplayName(user.display_name)
setPicture(user.picture);
setBanner(user.banner);
setAbout(user.about);
setWebsite(user.website);
setNip05(user.nip05);
setLud06(user.lud06);
setLud16(user.lud16);
}
}, [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"];
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 VoidUpload(file, file.name);
if (!rsp?.ok) {
throw "Upload failed, please try again later";
}
return rsp.file;
}
}
async function setNewAvatar() {
const rsp = await uploadFile();
if (rsp) {
setPicture(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`);
}
}
async function setNewBanner() {
const rsp = await uploadFile();
if (rsp) {
setBanner(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`);
}
}
async function saveRelays() {
let ev = await publisher.saveRelays();
publisher.broadcast(ev);
}
function editor() {
return (
<div className="editor">
<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 f-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)} />
<div className="btn" onClick={() => navigate("/verification")}>
<FontAwesomeIcon icon={faShop} />
&nbsp;
Buy
</div>
</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 className="btn" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</div>
</div>
<div>
<div className="btn" onClick={() => saveProfile()}>Save</div>
</div>
</div>
</div>
)
}
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))
}
}
function addRelay() {
return (
<>
<h4>Add Relays</h4>
<div className="flex mb10">
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
</div>
<div className="btn mb10" onClick={() => addNewRelay()}>Add</div>
</>
)
}
function settings() {
if (!id) return null;
return (
<>
<h1>Settings</h1>
<div className="flex f-center image-settings">
<div>
<h2>Avatar</h2>
<div style={{ backgroundImage: `url(${avatarPicture})` }} className="avatar">
<div className="edit" onClick={() => setNewAvatar()}>Edit</div>
</div>
</div>
<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">
{settings()}
{privKey && (<div className="flex f-col bg-grey">
<div>
<h4>Private Key:</h4>
</div>
<div>
<Copy text={hexToBech32("nsec", privKey)} />
</div>
</div>)}
<h4>Relays</h4>
<div className="flex f-col">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div>
<div className="flex actions">
<div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div>
</div>
{addRelay()}
<div className="settings-page">
<h2 onClick={() => navigate("/settings")}>Settings</h2>
<Outlet />
</div>
);
}
export const SettingsRoutes: RouteObject[] = [
{
path: "",
element: <SettingsIndex />
},
{
path: "profile",
element: <Profile />
},
{
path: "relays",
element: <Relay />
},
{
path: "preferences",
element: <Preferences />
}
]

View File

@ -0,0 +1,3 @@
.settings-nav h3 {
background-color: var(--note-bg);
}

View File

@ -0,0 +1,31 @@
import { faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "react-router-dom";
import "./Index.css";
const SettingsIndex = () => {
const navigate = useNavigate();
return (
<div className="settings-nav">
<div className="card" onClick={() => navigate("profile")}>
<FontAwesomeIcon icon={faUser} size="xl" className="mr10" />
Profile
</div>
<div className="card" onClick={() => navigate("relays")}>
<FontAwesomeIcon icon={faPlug} size="xl" className="mr10" />
Relays
</div>
<div className="card" onClick={() => navigate("preferences")}>
<FontAwesomeIcon icon={faGear} size="xl" className="mr10" />
Preferences
</div>
<div className="card" onClick={() => navigate("/donate")}>
<FontAwesomeIcon icon={faCircleDollarToSlot} size="xl" className="mr10" />
Donate
</div>
</div>
)
}
export default SettingsIndex;

View File

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

View File

@ -0,0 +1,55 @@
import { useDispatch, useSelector } from "react-redux";
import { setPreferences, UserPreferences } from "State/Login";
import { RootState } from "State/Store";
import "./Preferences.css";
const PreferencesPage = () => {
const dispatch = useDispatch();
const perf = useSelector<RootState, UserPreferences>(s => s.login.preferences);
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: "light" === e.target.value ? "light" : "dark" }))}>
<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, if disabled only the link will show</small>
</div>
<div>
<input type="checkbox" checked={perf.autoLoadMedia} onChange={e => dispatch(setPreferences({ ...perf, autoLoadMedia: e.target.checked }))} />
</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>
)
}
export default PreferencesPage;

View File

@ -1,4 +1,4 @@
import "./SettingsPage.css";
import "./Profile.css";
import Nostrich from "nostrich.jpg";
import { useEffect, useState } from "react";
@ -10,20 +10,16 @@ import { faShop } from "@fortawesome/free-solid-svg-icons";
import useEventPublisher from "Feed/EventPublisher";
import useProfile from "Feed/ProfileFeed";
import VoidUpload from "Feed/VoidUpload";
import { logout, setRelays } from "State/Login";
import { logout } from "State/Login";
import { hexToBech32, openFile } from "Util";
import Relay from "Element/Relay";
import Copy from "Element/Copy";
import { RootState } from "State/Store";
import { HexKey, UserMetadata } from "Nostr";
import { RelaySettings } from "Nostr/Connection";
import { MetadataCache } from "Db/User";
import { HexKey } from "Nostr";
export default function SettingsPage() {
export default function ProfileSettings() {
const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const relays = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const dispatch = useDispatch();
const user = useProfile(id)?.get(id || "");
const publisher = useEventPublisher();
@ -37,7 +33,6 @@ export default function SettingsPage() {
const [nip05, setNip05] = useState<string>();
const [lud06, setLud06] = useState<string>();
const [lud16, setLud16] = useState<string>();
const [newRelay, setNewRelay] = useState<string>();
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
@ -104,11 +99,6 @@ export default function SettingsPage() {
}
}
async function saveRelays() {
let ev = await publisher.saveRelays();
publisher.broadcast(ev);
}
function editor() {
return (
<div className="editor">
@ -165,37 +155,10 @@ export default function SettingsPage() {
)
}
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))
}
}
function addRelay() {
return (
<>
<h4>Add Relays</h4>
<div className="flex mb10">
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
</div>
<div className="btn mb10" onClick={() => addNewRelay()}>Add</div>
</>
)
}
function settings() {
if (!id) return null;
return (
<>
<h1>Settings</h1>
<div className="flex f-center image-settings">
<div>
<h2>Avatar</h2>
@ -217,6 +180,7 @@ export default function SettingsPage() {
return (
<div className="settings">
<h3>Profile</h3>
{settings()}
{privKey && (<div className="flex f-col bg-grey">
<div>
@ -226,15 +190,6 @@ export default function SettingsPage() {
<Copy text={hexToBech32("nsec", privKey)} />
</div>
</div>)}
<h4>Relays</h4>
<div className="flex f-col">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div>
<div className="flex actions">
<div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div>
</div>
{addRelay()}
</div>
);
}

View File

@ -0,0 +1,63 @@
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import Relay from "Element/Relay";
import useEventPublisher from "Feed/EventPublisher";
import { RootState } from "State/Store";
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>();
async function saveRelays() {
let ev = await publisher.saveRelays();
publisher.broadcast(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>
<div className="btn mb10" onClick={() => addNewRelay()}>Add</div>
</>
)
}
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">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div>
<div className="flex actions">
<div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div>
</div>
{addRelay()}
</>
)
}
export default RelaySettingsPage;

View File

@ -3,12 +3,36 @@ import * as secp from '@noble/secp256k1';
import { DefaultRelays } from 'Const';
import { HexKey, RawEvent, TaggedRawEvent } from 'Nostr';
import { RelaySettings } from 'Nostr/Connection';
import { useDispatch } from 'react-redux';
const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey";
const NotificationsReadItem = "notifications-read";
const UserPreferencesKey = "preferences";
interface LoginStore {
export interface UserPreferences {
/**
* Enable reactions / reposts / zaps
*/
enableReactions: boolean,
/**
* Automatically load media (show link only) (bandwidth/privacy)
*/
autoLoadMedia: boolean,
/**
* Select between light/dark theme
*/
theme: "light" | "dark",
/**
* Ask for confirmation when reposting notes
*/
confirmReposts: boolean
}
export interface LoginStore {
/**
* If there is no login
*/
@ -57,7 +81,12 @@ interface LoginStore {
/**
* Counter to trigger refresh of unread dms
*/
dmInteraction: 0
dmInteraction: 0,
/**
* Users cusom preferences
*/
preferences: UserPreferences
};
const InitState = {
@ -70,7 +99,13 @@ const InitState = {
notifications: [],
readNotifications: new Date().getTime(),
dms: [],
dmInteraction: 0
dmInteraction: 0,
preferences: {
enableReactions: true,
autoLoadMedia: true,
theme: "dark",
confirmReposts: false
}
} as LoginStore;
export interface SetRelaysPayload {
@ -106,6 +141,16 @@ const LoginSlice = createSlice({
if (!isNaN(readNotif)) {
state.readNotifications = readNotif;
}
// preferences
let pref = window.localStorage.getItem(UserPreferencesKey);
if (pref) {
state.preferences = JSON.parse(pref);
} else {
// get os defaults
const osTheme = window.matchMedia("(prefers-color-scheme: light)");
state.preferences.theme = osTheme.matches ? "light" : "dark";
}
},
setPrivateKey: (state, action: PayloadAction<HexKey>) => {
state.loggedOut = false;
@ -205,6 +250,10 @@ const LoginSlice = createSlice({
markNotificationsRead: (state) => {
state.readNotifications = new Date().getTime();
window.localStorage.setItem(NotificationsReadItem, state.readNotifications.toString());
},
setPreferences: (state, action: PayloadAction<UserPreferences>) => {
state.preferences = action.payload;
window.localStorage.setItem(UserPreferencesKey, JSON.stringify(state.preferences));
}
}
});
@ -220,6 +269,7 @@ export const {
addDirectMessage,
incDmInteraction,
logout,
markNotificationsRead
markNotificationsRead,
setPreferences
} = LoginSlice.actions;
export const reducer = LoginSlice.reducer;

View File

@ -1,53 +1,51 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap');
:root {
--bg-color: #000;
--font-color: #FFF;
--font-secondary-color: #555;
--font-tertiary-color: #666;
--font-size: 16px;
--font-size-small: 14px;
--font-size-tiny: 12px;
--modal-bg-color: rgba(0,0,0, 0.8);
--note-bg: #111;
--highlight-light: #ffd342;
--highlight: #ffc400;
--highlight-dark: #dba800;
--error: #FF6053;
--success: #2AD544;
--bg-color: #000;
--font-color: #FFF;
--font-secondary-color: #555;
--font-tertiary-color: #666;
--font-size: 16px;
--font-size-small: 14px;
--font-size-tiny: 12px;
--modal-bg-color: rgba(0, 0, 0, 0.8);
--note-bg: #111;
--highlight-light: #ffd342;
--highlight: #ffc400;
--highlight-dark: #dba800;
--error: #FF6053;
--success: #2AD544;
--gray-superlight: #EEE;
--gray-light: #999;
--gray-medium: #666;
--gray: #333;
--gray-secondary: #222;
--gray-tertiary: #444;
--gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light));
--snort-gradient: linear-gradient(to bottom right, var(--highlight-light), var(--highlight), var(--highlight-dark));
--nostrplebs-gradient: linear-gradient(to bottom right, #ff3cac, #2b86c5);
--strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900);
--gray-superlight: #EEE;
--gray-light: #999;
--gray-medium: #666;
--gray: #333;
--gray-secondary: #222;
--gray-tertiary: #444;
--gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light));
--snort-gradient: linear-gradient(to bottom right, var(--highlight-light), var(--highlight), var(--highlight-dark));
--nostrplebs-gradient: linear-gradient(to bottom right, #ff3cac, #2b86c5);
--strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900);
}
@media (prefers-color-scheme: light) {
:root {
--bg-color: #F1F1F1;
--font-color: #57534E;
--font-secondary-color: #B9B9B9;
--font-tertiary-color: #F3F3F3;
html.light {
--bg-color: #F1F1F1;
--font-color: #57534E;
--font-secondary-color: #B9B9B9;
--font-tertiary-color: #F3F3F3;
--highlight-light: #16AAC1;
--highlight: #0284C7;
--highlight-dark: #0A52B5;
--modal-bg-color: rgba(240, 240, 240, 0.8);
--highlight-light: #16AAC1;
--highlight: #0284C7;
--highlight-dark: #0A52B5;
--modal-bg-color: rgba(240, 240, 240, 0.8);
--note-bg: white;
--note-bg: white;
--gray: #CCC;
--gray-secondary: #DDD;
--gray-tertiary: #EEE;
--gray-superlight: #333;
--gray-light: #555;
}
--gray: #CCC;
--gray-secondary: #DDD;
--gray-tertiary: #EEE;
--gray-superlight: #333;
--gray-light: #555;
}
body {
@ -70,18 +68,18 @@ code {
margin-right: auto;
}
.page > .header {
.page>.header {
display: flex;
align-items: center;
margin: 10px 0;
}
.page > .header > div:nth-child(1) {
.page>.header>div:nth-child(1) {
font-size: x-large;
flex-grow: 1;
}
.page > .header > div:nth-child(2) {
.page>.header>div:nth-child(2) {
display: flex;
align-items: center;
}
@ -92,299 +90,318 @@ code {
background-color: var(--note-bg);
padding: 6px 12px;
}
@media (min-width: 720px) {
.card { margin-bottom: 24px; padding: 12px 24px; }
<<<<<<< HEAD .card {
margin-bottom: 24px;
padding: 12px 24px;
}
}
@media (prefers-color-scheme: light) {
.card {
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05);
=======.card {
margin-bottom: 24px;
padding: 24px;
>>>>>>>3e41614 (feat: user preferences)
}
}
}
.card .header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.card .header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.card > .footer {
display: flex;
flex-direction: row-reverse;
margin-top: 12px;
}
.card>.footer {
display: flex;
flex-direction: row-reverse;
margin-top: 12px;
}
.btn {
padding: 10px;
border-radius: 5px;
cursor: pointer;
user-select: none;
background-color: var(--bg-color);
color: var(--font-color);
border: 1px solid;
display: inline-block;
}
.btn {
padding: 10px;
border-radius: 5px;
cursor: pointer;
user-select: none;
background-color: var(--bg-color);
color: var(--font-color);
border: 1px solid;
display: inline-block;
}
.btn-warn {
border-color: var(--error);
}
.btn-warn {
border-color: var(--error);
}
.btn-success {
border-color: var(--success);
}
.btn-success {
border-color: var(--success);
}
.btn.active {
border: 2px solid;
background-color: var(--gray-secondary);
color: var(--font-color);
font-weight: bold;
}
.btn.active {
border: 2px solid;
background-color: var(--gray-secondary);
color: var(--font-color);
font-weight: bold;
}
.btn.disabled {
color: var(--gray-light);
}
.btn.disabled {
color: var(--gray-light);
}
.btn:hover {
background-color: var(--gray);
}
.btn:hover {
background-color: var(--gray);
}
.btn-sm {
padding: 5px;
}
.btn-sm {
padding: 5px;
}
.btn-rnd {
border-radius: 100%;
}
.btn-rnd {
border-radius: 100%;
}
textarea {
font: inherit;
}
textarea {
font: inherit;
}
input[type="text"], input[type="password"], input[type="number"], textarea, select {
padding: 10px;
border-radius: 5px;
border: 0;
background-color: var(--gray);
color: var(--font-color);
}
input[type="text"], input[type="password"], input[type="number"], textarea, select {
padding: 10px;
border-radius: 5px;
border: 0;
background-color: var(--gray);
color: var(--font-color);
}
input:disabled {
color: var(--gray-medium);
cursor:not-allowed;
}
input[type="checkbox"] {
width: 24px;
height: 24px;
}
textarea:placeholder {
color: var(--gray-superlight);
}
input:disabled {
color: var(--gray-medium);
cursor: not-allowed;
}
.flex {
display: flex;
align-items: center;
min-width: 0;
}
textarea:placeholder {
color: var(--gray-superlight);
}
.f-center {
justify-content: center;
}
.flex {
display: flex;
align-items: center;
min-width: 0;
}
.f-1 {
flex: 1;
}
.f-center {
justify-content: center;
}
.f-2 {
flex: 2;
}
.f-1 {
flex: 1;
}
.f-grow {
flex-grow: 1;
min-width: 0;
}
.f-2 {
flex: 2;
}
.f-shrink {
flex-shrink: 1;
}
.f-grow {
flex-grow: 1;
min-width: 0;
}
.f-ellipsis {
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.f-shrink {
flex-shrink: 1;
}
.f-col {
flex-direction: column;
align-items: flex-start !important;
}
.f-ellipsis {
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.w-max {
width: 100%;
width: -moz-available;
width: -webkit-fill-available;
width: fill-available;
}
.f-col {
flex-direction: column;
align-items: flex-start !important;
}
.w-max-w {
max-width: 100%;
max-width: -moz-available;
max-width: -webkit-fill-available;
max-width: fill-available;
}
.w-max {
width: 100%;
width: -moz-available;
width: -webkit-fill-available;
width: fill-available;
}
a {
color: inherit;
line-height: 1.3em;
}
.w-max-w {
max-width: 100%;
max-width: -moz-available;
max-width: -webkit-fill-available;
max-width: fill-available;
}
a.ext {
word-break: break-all;
white-space: initial;
}
a {
color: inherit;
line-height: 1.3em;
}
div.form-group {
display: flex;
align-items: center;
}
a.ext {
word-break: break-all;
white-space: initial;
}
div.form-group > div {
padding: 3px 5px;
word-break: break-word;
}
div.form-group {
display: flex;
align-items: center;
}
div.form-group > div:nth-child(1) {
min-width: 100px;
}
div.form-group>div {
padding: 3px 5px;
word-break: break-word;
}
div.form-group > div:nth-child(2) {
display: flex;
flex-grow: 1;
justify-content: end;
}
div.form-group>div:nth-child(1) {
min-width: 100px;
}
div.form-group > div:nth-child(2) input {
flex-grow: 1;
}
div.form-group>div:nth-child(2) {
display: flex;
flex-grow: 1;
justify-content: end;
}
.modal {
position: absolute;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
background-color: rgba(0,0,0,0.8);
}
div.form-group>div:nth-child(2) input {
flex-grow: 1;
}
.modal .modal-content {
display: flex;
justify-content: center;
}
.modal {
position: absolute;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.8);
}
.modal .modal-content > div {
padding: 10px;
border-radius: 10px;
background-color: var(--gray);
margin-top: 5vh;
}
.modal .modal-content {
display: flex;
justify-content: center;
}
body.scroll-lock {
overflow: hidden;
height: 100vh;
}
.modal .modal-content>div {
padding: 10px;
border-radius: 10px;
background-color: var(--gray);
margin-top: 5vh;
}
.m5 {
margin: 5px;
}
body.scroll-lock {
overflow: hidden;
height: 100vh;
}
.m10 {
margin: 10px;
}
.m5 {
margin: 5px;
}
.mr10 {
margin-right: 10px;
}
.m10 {
margin: 10px;
}
.mr5 {
margin-right: 5px;
}
.mr10 {
margin-right: 10px;
}
.ml5 {
margin-left: 5px;
}
.mr5 {
margin-right: 5px;
}
.mb10 {
margin-bottom: 10px;
}
.ml5 {
margin-left: 5px;
}
.tabs {
display: flex;
align-content: center;
text-align: center;
margin: 10px 0;
overflow-x: auto;
}
.mb10 {
margin-bottom: 10px;
}
.tabs > div {
margin-right: 10px;
cursor: pointer;
}
.tabs {
display: flex;
align-content: center;
text-align: center;
margin: 10px 0;
overflow-x: auto;
}
.tabs > div:last-child {
margin: 0;
}
.tabs>div {
margin-right: 10px;
cursor: pointer;
}
.tabs .active {
font-weight: bold;
}
.tabs>div:last-child {
margin: 0;
}
.error {
color: var(--error);
}
.tabs .active {
font-weight: bold;
}
.bg-error {
background-color: var(--error);
}
.error {
color: var(--error);
}
.bg-success {
background-color: var(--success);
}
.bg-error {
background-color: var(--error);
}
.root-tabs {
.bg-success {
background-color: var(--success);
}
.root-tabs {
padding: 0;
align-items: center;
justify-content: flex-start;
}
}
.root-tab {
.root-tab {
border-bottom: 3px solid var(--gray-secondary);
}
.root-tab.active {
border-bottom: 3px solid var(--highlight);
}
}
.tweet {
.root-tab.active {
border-bottom: 3px solid var(--highlight);
}
.tweet {
display: flex;
align-items: center;
justify-content: center;
}
}
.tweet div {
.tweet div {
width: 100%;
}
}
.tweet div .twitter-tweet {
.tweet div .twitter-tweet {
margin: 0 auto;
}
}
.tweet div .twitter-tweet > iframe {
.tweet div .twitter-tweet>iframe {
max-height: unset;
}
@media(max-width: 720px) {
.page {
width: calc(100vw - 8px);
}
div.form-group {
flex-direction: column;
align-items: flex-start;
}
}
.highlight { color: var(--highlight); }
@media(max-width: 720px) {
.page {
width: calc(100vw - 8px);
}
div.form-group {
flex-direction: column;
align-items: flex-start;
}
}
.highlight {
color: var(--highlight);
}

View File

@ -19,7 +19,7 @@ import ProfilePage from 'Pages/ProfilePage';
import RootPage from 'Pages/Root';
import NotificationsPage from 'Pages/Notifications';
import NewUserPage from 'Pages/NewUserPage';
import SettingsPage from 'Pages/SettingsPage';
import SettingsPage, { SettingsRoutes } from 'Pages/SettingsPage';
import ErrorPage from 'Pages/ErrorPage';
import VerificationPage from 'Pages/Verification';
import MessagesPage from 'Pages/MessagesPage';
@ -65,7 +65,8 @@ const router = createBrowserRouter([
},
{
path: "/settings",
element: <SettingsPage />
element: <SettingsPage />,
children: SettingsRoutes
},
{
path: "/verification",