commit
34762d7039
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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]);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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, setPreferences, 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,27 @@ export default function Layout() {
|
||||
}
|
||||
}, [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");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let osTheme = window.matchMedia("(prefers-color-scheme: light)");
|
||||
setTheme(prefs.theme === "system" && osTheme.matches ? "light" : prefs.theme === "light" ? "light" : "dark");
|
||||
|
||||
osTheme.onchange = (e) => {
|
||||
if (prefs.theme === "system") {
|
||||
setTheme(e.matches ? "light" : "dark");
|
||||
}
|
||||
}
|
||||
return () => { osTheme.onchange = null; }
|
||||
}, [prefs.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(init());
|
||||
}, []);
|
||||
|
@ -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} />
|
||||
|
||||
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>
|
||||
<>
|
||||
<h2 onClick={() => navigate("/settings")} className="pointer">Settings</h2>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const SettingsRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "",
|
||||
element: <SettingsIndex />
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
element: <Profile />
|
||||
},
|
||||
{
|
||||
path: "relays",
|
||||
element: <Relay />
|
||||
},
|
||||
{
|
||||
path: "preferences",
|
||||
element: <Preferences />
|
||||
}
|
||||
]
|
||||
|
3
src/Pages/settings/Index.css
Normal file
3
src/Pages/settings/Index.css
Normal file
@ -0,0 +1,3 @@
|
||||
.settings-nav .card {
|
||||
cursor: pointer;
|
||||
}
|
31
src/Pages/settings/Index.tsx
Normal file
31
src/Pages/settings/Index.tsx
Normal 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;
|
8
src/Pages/settings/Preferences.css
Normal file
8
src/Pages/settings/Preferences.css
Normal file
@ -0,0 +1,8 @@
|
||||
.preferences small {
|
||||
margin-top: 0.5em;
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.preferences select {
|
||||
min-width: 100px;
|
||||
}
|
56
src/Pages/settings/Preferences.tsx
Normal file
56
src/Pages/settings/Preferences.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
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: 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, 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;
|
@ -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>
|
||||
);
|
||||
}
|
63
src/Pages/settings/Relays.tsx
Normal file
63
src/Pages/settings/Relays.tsx
Normal 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;
|
@ -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: "system" | "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: "system",
|
||||
confirmReposts: false
|
||||
}
|
||||
} as LoginStore;
|
||||
|
||||
export interface SetRelaysPayload {
|
||||
@ -106,6 +141,12 @@ const LoginSlice = createSlice({
|
||||
if (!isNaN(readNotif)) {
|
||||
state.readNotifications = readNotif;
|
||||
}
|
||||
|
||||
// preferences
|
||||
let pref = window.localStorage.getItem(UserPreferencesKey);
|
||||
if (pref) {
|
||||
state.preferences = JSON.parse(pref);
|
||||
}
|
||||
},
|
||||
setPrivateKey: (state, action: PayloadAction<HexKey>) => {
|
||||
state.loggedOut = false;
|
||||
@ -205,6 +246,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 +265,7 @@ export const {
|
||||
addDirectMessage,
|
||||
incDmInteraction,
|
||||
logout,
|
||||
markNotificationsRead
|
||||
markNotificationsRead,
|
||||
setPreferences
|
||||
} = LoginSlice.actions;
|
||||
export const reducer = LoginSlice.reducer;
|
157
src/index.css
157
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,28 +90,32 @@ code {
|
||||
background-color: var(--note-bg);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.card { margin-bottom: 24px; padding: 12px 24px; }
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.card {
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
html.light .card {
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.card .header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card > .footer {
|
||||
.card>.footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
.btn {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
@ -135,7 +137,7 @@ code {
|
||||
.btn.active {
|
||||
border: 2px solid;
|
||||
background-color: var(--gray-secondary);
|
||||
color: var(--font-color);
|
||||
color: var(--font-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@ -167,9 +169,14 @@ input[type="text"], input[type="password"], input[type="number"], textarea, sele
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: var(--gray-medium);
|
||||
cursor:not-allowed;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea:placeholder {
|
||||
@ -244,22 +251,22 @@ div.form-group {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.form-group > div {
|
||||
div.form-group>div {
|
||||
padding: 3px 5px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
div.form-group > div:nth-child(1) {
|
||||
div.form-group>div:nth-child(1) {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
div.form-group > div:nth-child(2) {
|
||||
div.form-group>div:nth-child(2) {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
div.form-group > div:nth-child(2) input {
|
||||
div.form-group>div:nth-child(2) input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@ -269,7 +276,7 @@ div.form-group > div:nth-child(2) input {
|
||||
height: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0,0,0,0.8);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.modal .modal-content {
|
||||
@ -277,7 +284,7 @@ div.form-group > div:nth-child(2) input {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal .modal-content > div {
|
||||
.modal .modal-content>div {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--gray);
|
||||
@ -321,12 +328,12 @@ body.scroll-lock {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabs > div {
|
||||
.tabs>div {
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs > div:last-child {
|
||||
.tabs>div:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -347,44 +354,48 @@ body.scroll-lock {
|
||||
}
|
||||
|
||||
.root-tabs {
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.root-tab {
|
||||
border-bottom: 3px solid var(--gray-secondary);
|
||||
border-bottom: 3px solid var(--gray-secondary);
|
||||
}
|
||||
|
||||
.root-tab.active {
|
||||
border-bottom: 3px solid var(--highlight);
|
||||
border-bottom: 3px solid var(--highlight);
|
||||
}
|
||||
|
||||
.tweet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tweet div {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tweet div .twitter-tweet {
|
||||
margin: 0 auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tweet div .twitter-tweet > iframe {
|
||||
max-height: unset;
|
||||
.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); }
|
||||
.highlight {
|
||||
color: var(--highlight);
|
||||
}
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user