From 5eb0623fb8ce7f1aae4aa295147da6b9d7792ff9 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 20 Jan 2023 17:07:14 +0000 Subject: [PATCH] feat: user preferences --- src/Element/NoteFooter.tsx | 244 ++++---- src/Element/Text.tsx | 44 +- src/Feed/ThreadFeed.ts | 6 +- src/Feed/TimelineFeed.ts | 6 +- src/Pages/DonatePage.tsx | 14 +- src/Pages/Layout.tsx | 12 +- src/Pages/SettingsPage.tsx | 259 +------- src/Pages/settings/Index.css | 3 + src/Pages/settings/Index.tsx | 31 + src/Pages/settings/Preferences.css | 8 + src/Pages/settings/Preferences.tsx | 55 ++ .../Profile.css} | 0 .../{ProfileSettings.tsx => Profile.tsx} | 55 +- src/Pages/settings/Relays.tsx | 63 ++ src/State/Login.ts | 58 +- src/index.css | 553 +++++++++--------- src/index.tsx | 5 +- 17 files changed, 715 insertions(+), 701 deletions(-) create mode 100644 src/Pages/settings/Index.css create mode 100644 src/Pages/settings/Index.tsx create mode 100644 src/Pages/settings/Preferences.css create mode 100644 src/Pages/settings/Preferences.tsx rename src/Pages/{SettingsPage.css => settings/Profile.css} (100%) rename src/Pages/settings/{ProfileSettings.tsx => Profile.tsx} (78%) create mode 100644 src/Pages/settings/Relays.tsx diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 728c03ea..ce405743 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -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(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(s => s.login.publicKey); + const prefs = useSelector(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 ( - <> -
setTip(true)}> -
- -
-
- - ) - } - return null; - } - - function reactionIcon(content: string, reacted: boolean) { - switch (content) { - case Reaction.Positive: { - return ; - } - case Reaction.Negative: { - return ; - } - } - return content; - } - - function repostIcon() { + function tipButton() { + let service = author?.lud16 || author?.lud06; + if (service) { return ( -
repost()}> + <> +
setTip(true)}>
- +
- {reposts.length > 0 && ( -
- {formatShort(reposts.length)} -
- )}
+ ) } + return null; + } - + function repostIcon() { return ( - <> -
-
setReply(s => !s)}> -
- -
-
-
react("+")}> -
- -
-
- {formatShort(groupReactions[Reaction.Positive])} -
-
-
react("-")}> -
- -
-
- {formatShort(groupReactions[Reaction.Negative])} -
-
- {repostIcon()} - {tipButton()} - {isMine && ( -
-
- deleteEvent()} /> -
-
- )} -
- setReply(false)} - show={reply} - /> - setTip(false)} show={tip} /> - +
repost()}> +
+ +
+ {reposts.length > 0 && ( +
+ {formatShort(reposts.length)} +
+ )} +
) + } + + function reactionIcons() { + if (!prefs.enableReactions) { + return null; + } + return ( + <> +
react("+")}> +
+ +
+
+ {formatShort(groupReactions[Reaction.Positive])} +
+
+
react("-")}> +
+ +
+
+ {formatShort(groupReactions[Reaction.Negative])} +
+
+ {repostIcon()} + + ) + } + + return ( + <> +
+
setReply(s => !s)}> +
+ +
+
+ {reactionIcons()} + {tipButton()} + {isMine && ( +
+
+ deleteEvent()} /> +
+
+ )} +
+ setReply(false)} + show={reply} + /> + setTip(false)} show={tip} /> + + ) } diff --git a/src/Element/Text.tsx b/src/Element/Text.tsx index 367922df..b1d26b14 100644 --- a/src/Element/Text.tsx +++ b/src/Element/Text.tsx @@ -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 e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{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 e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{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) { - 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
  • {fragments}
  • } -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

    {fragments}

    } 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 + users: Map, + pref: UserPreferences } export interface TextProps { @@ -188,11 +195,12 @@ export interface TextProps { } export default function Text({ content, tags, users }: TextProps) { + const pref = useSelector(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 {content} diff --git a/src/Feed/ThreadFeed.ts b/src/Feed/ThreadFeed.ts index 3fadc965..e13b9358 100644 --- a/src/Feed/ThreadFeed.ts +++ b/src/Feed/ThreadFeed.ts @@ -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([id]); + const pref = useSelector(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); diff --git a/src/Feed/TimelineFeed.ts b/src/Feed/TimelineFeed.ts index e97f7bdd..4118d950 100644 --- a/src/Feed/TimelineFeed.ts +++ b/src/Feed/TimelineFeed.ts @@ -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(now); const [since, setSince] = useState(now - window); const [trackingEvents, setTrackingEvent] = useState([]); + const pref = useSelector(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]); diff --git a/src/Pages/DonatePage.tsx b/src/Pages/DonatePage.tsx index 516afd81..ab01785e 100644 --- a/src/Pages/DonatePage.tsx +++ b/src/Pages/DonatePage.tsx @@ -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 = () => {

    Check out the code here: https://github.com/v0l/snort

    -

    Developers

    +

    Primary Developers

    {Developers.map(a => } />)} +

    Contributors

    + {Contributors.map(a => } />)}
    ); } diff --git a/src/Pages/Layout.tsx b/src/Pages/Layout.tsx index 9129a2dd..776ff6e1 100644 --- a/src/Pages/Layout.tsx +++ b/src/Pages/Layout.tsx @@ -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(s => s.login.notifications); const readNotifications = useSelector(s => s.login.readNotifications); const dms = useSelector(s => s.login.dms); + const prefs = useSelector(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()); }, []); diff --git a/src/Pages/SettingsPage.tsx b/src/Pages/SettingsPage.tsx index 5202445a..cf78ed44 100644 --- a/src/Pages/SettingsPage.tsx +++ b/src/Pages/SettingsPage.tsx @@ -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(s => s.login.publicKey); - const privKey = useSelector(s => s.login.privateKey); - const relays = useSelector>(s => s.login.relays); - const dispatch = useDispatch(); - const user = useProfile(id)?.get(id || ""); - const publisher = useEventPublisher(); - - const [name, setName] = useState(); - const [displayName, setDisplayName] = useState(); - const [picture, setPicture] = useState(); - const [banner, setBanner] = useState(); - const [about, setAbout] = useState(); - const [website, setWebsite] = useState(); - const [nip05, setNip05] = useState(); - const [lud06, setLud06] = useState(); - const [lud16, setLud16] = useState(); - const [newRelay, setNewRelay] = useState(); - - const 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 ( -
    -
    -
    Name:
    -
    - setName(e.target.value)} /> -
    -
    -
    -
    Display name:
    -
    - setDisplayName(e.target.value)} /> -
    -
    -
    -
    About:
    -
    - -
    -
    -
    -
    Website:
    -
    - setWebsite(e.target.value)} /> -
    -
    -
    -
    NIP-05:
    -
    - setNip05(e.target.value)} /> -
    navigate("/verification")}> - -   - Buy -
    -
    -
    -
    -
    LN Address:
    -
    - setLud16(e.target.value)} /> -
    -
    -
    -
    -
    { dispatch(logout()); navigate("/"); }}>Logout
    -
    -
    -
    saveProfile()}>Save
    -
    -
    -
    - ) - } - - 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 ( - <> -

    Add Relays

    -
    - setNewRelay(e.target.value)} /> -
    -
    addNewRelay()}>Add
    - - ) - } - - function settings() { - if (!id) return null; - return ( - <> -

    Settings

    -
    -
    -

    Avatar

    -
    -
    setNewAvatar()}>Edit
    -
    -
    -
    -

    Header

    -
    -
    setNewBanner()}>Edit
    -
    -
    -
    - {editor()} - - ) - } return ( -
    - {settings()} - {privKey && (
    -
    -

    Private Key:

    -
    -
    - -
    -
    )} -

    Relays

    -
    - {Object.keys(relays || {}).map(a => )} -
    -
    -
    -
    saveRelays()}>Save
    -
    - {addRelay()} +
    +

    navigate("/settings")}>Settings

    +
    ); } + +export const SettingsRoutes: RouteObject[] = [ + { + path: "", + element: + }, + { + path: "profile", + element: + }, + { + path: "relays", + element: + }, + { + path: "preferences", + element: + } +] diff --git a/src/Pages/settings/Index.css b/src/Pages/settings/Index.css new file mode 100644 index 00000000..39554bda --- /dev/null +++ b/src/Pages/settings/Index.css @@ -0,0 +1,3 @@ +.settings-nav h3 { + background-color: var(--note-bg); +} \ No newline at end of file diff --git a/src/Pages/settings/Index.tsx b/src/Pages/settings/Index.tsx new file mode 100644 index 00000000..5fc6ae1b --- /dev/null +++ b/src/Pages/settings/Index.tsx @@ -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 ( +
    +
    navigate("profile")}> + + Profile +
    +
    navigate("relays")}> + + Relays +
    +
    navigate("preferences")}> + + Preferences +
    +
    navigate("/donate")}> + + Donate +
    +
    + ) +} + +export default SettingsIndex; \ No newline at end of file diff --git a/src/Pages/settings/Preferences.css b/src/Pages/settings/Preferences.css new file mode 100644 index 00000000..c2db1bba --- /dev/null +++ b/src/Pages/settings/Preferences.css @@ -0,0 +1,8 @@ +.preferences small { + margin-top: 0.5em; + color: var(--font-secondary-color); +} + +.preferences select { + min-width: 100px; +} \ No newline at end of file diff --git a/src/Pages/settings/Preferences.tsx b/src/Pages/settings/Preferences.tsx new file mode 100644 index 00000000..80721600 --- /dev/null +++ b/src/Pages/settings/Preferences.tsx @@ -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(s => s.login.preferences); + + return ( +
    +

    Preferences

    + +
    +
    +
    Theme
    +
    +
    + +
    +
    +
    +
    +
    Automatically load media
    + Media in posts will automatically be shown, if disabled only the link will show +
    +
    + dispatch(setPreferences({ ...perf, autoLoadMedia: e.target.checked }))} /> +
    +
    +
    +
    +
    Enable reactions
    + Reactions will be shown on every page, if disabled no reactions will be shown +
    +
    + dispatch(setPreferences({ ...perf, enableReactions: e.target.checked }))} /> +
    +
    +
    +
    +
    Confirm reposts
    + Reposts need to be manually confirmed +
    +
    + dispatch(setPreferences({ ...perf, confirmReposts: e.target.checked }))} /> +
    +
    +
    + ) +} +export default PreferencesPage; \ No newline at end of file diff --git a/src/Pages/SettingsPage.css b/src/Pages/settings/Profile.css similarity index 100% rename from src/Pages/SettingsPage.css rename to src/Pages/settings/Profile.css diff --git a/src/Pages/settings/ProfileSettings.tsx b/src/Pages/settings/Profile.tsx similarity index 78% rename from src/Pages/settings/ProfileSettings.tsx rename to src/Pages/settings/Profile.tsx index 0fa5d5aa..cd5b3cd7 100644 --- a/src/Pages/settings/ProfileSettings.tsx +++ b/src/Pages/settings/Profile.tsx @@ -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(s => s.login.publicKey); const privKey = useSelector(s => s.login.privateKey); - const relays = useSelector>(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(); const [lud06, setLud06] = useState(); const [lud16, setLud16] = useState(); - const [newRelay, setNewRelay] = useState(); 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 (
    @@ -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 ( - <> -

    Add Relays

    -
    - setNewRelay(e.target.value)} /> -
    -
    addNewRelay()}>Add
    - - ) - } - function settings() { if (!id) return null; return ( <> -

    Settings

    Avatar

    @@ -217,6 +180,7 @@ export default function SettingsPage() { return (
    +

    Profile

    {settings()} {privKey && (
    @@ -226,15 +190,6 @@ export default function SettingsPage() {
    )} -

    Relays

    -
    - {Object.keys(relays || {}).map(a => )} -
    -
    -
    -
    saveRelays()}>Save
    -
    - {addRelay()}
    ); } diff --git a/src/Pages/settings/Relays.tsx b/src/Pages/settings/Relays.tsx new file mode 100644 index 00000000..bb81c1e7 --- /dev/null +++ b/src/Pages/settings/Relays.tsx @@ -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>(s => s.login.relays); + const [newRelay, setNewRelay] = useState(); + + async function saveRelays() { + let ev = await publisher.saveRelays(); + publisher.broadcast(ev); + } + + + function addRelay() { + return ( + <> +

    Add Relays

    +
    + setNewRelay(e.target.value)} /> +
    +
    addNewRelay()}>Add
    + + ) + } + + 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 ( + <> +

    Relays

    +
    + {Object.keys(relays || {}).map(a => )} +
    +
    +
    +
    saveRelays()}>Save
    +
    + {addRelay()} + + ) +} + +export default RelaySettingsPage; \ No newline at end of file diff --git a/src/State/Login.ts b/src/State/Login.ts index 7365c806..ccb7ec42 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -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) => { 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) => { + 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; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 7aa4c505..3c600022 100644 --- a/src/index.css +++ b/src/index.css @@ -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); + } \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 2ae7f46f..a8c84a0e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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: + element: , + children: SettingsRoutes }, { path: "/verification",