commit
e019673971
@ -25,4 +25,5 @@ Snort supports the following NIP's
|
||||
- [ ] NIP-28: Public Chat
|
||||
- [ ] NIP-36: Sensitive Content
|
||||
- [ ] NIP-40: Expiration Timestamp
|
||||
- [ ] NIP-42: Authentication of clients to relays
|
||||
- [ ] NIP-42: Authentication of clients to relays
|
||||
- [ ] NIP-51: Lists
|
||||
|
21
src/Element/BlockButton.tsx
Normal file
21
src/Element/BlockButton.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { HexKey } from "Nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
interface BlockButtonProps {
|
||||
pubkey: HexKey
|
||||
}
|
||||
|
||||
const BlockButton = ({ pubkey }: BlockButtonProps) => {
|
||||
const { block, unblock, isBlocked } = useModeration()
|
||||
return isBlocked(pubkey) ? (
|
||||
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
|
||||
Unblock
|
||||
</button>
|
||||
) : (
|
||||
<button className="secondary" type="button" onClick={() => block(pubkey)}>
|
||||
Block
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlockButton
|
39
src/Element/BlockList.tsx
Normal file
39
src/Element/BlockList.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { HexKey } from "Nostr"; import type { RootState } from "State/Store";
|
||||
import MuteButton from "Element/MuteButton";
|
||||
import BlockButton from "Element/BlockButton";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useMutedFeed, { getMuted } from "Feed/MuteList";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
interface BlockListProps {
|
||||
variant: "muted" | "blocked"
|
||||
}
|
||||
|
||||
export default function BlockList({ variant }: BlockListProps) {
|
||||
const { publicKey } = useSelector((s: RootState) => s.login)
|
||||
const { blocked, muted } = useModeration();
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
{variant === "muted" && (
|
||||
<>
|
||||
<h4>{muted.length} muted</h4>
|
||||
{muted.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{variant === "blocked" && (
|
||||
<>
|
||||
<h4>{blocked.length} blocked</h4>
|
||||
{blocked.map(a => {
|
||||
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -79,9 +79,9 @@ export default function Invoice(props: InvoiceProps) {
|
||||
</div>
|
||||
|
||||
{info?.expired ? <div className="btn">Expired</div> : (
|
||||
<div className="btn" onClick={payInvoice}>
|
||||
<button type="button" onClick={payInvoice}>
|
||||
Pay
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
14
src/Element/LogoutButton.tsx
Normal file
14
src/Element/LogoutButton.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { logout } from "State/Login";
|
||||
|
||||
export default function LogoutButton(){
|
||||
const dispatch = useDispatch()
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>
|
||||
Logout
|
||||
</button>
|
||||
)
|
||||
}
|
21
src/Element/MuteButton.tsx
Normal file
21
src/Element/MuteButton.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { HexKey } from "Nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
interface MuteButtonProps {
|
||||
pubkey: HexKey
|
||||
}
|
||||
|
||||
const MuteButton = ({ pubkey }: MuteButtonProps) => {
|
||||
const { mute, unmute, isMuted } = useModeration()
|
||||
return isMuted(pubkey) ? (
|
||||
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
|
||||
Unmute
|
||||
</button>
|
||||
) : (
|
||||
<button className="secondary" type="button" onClick={() => mute(pubkey)}>
|
||||
Mute
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default MuteButton
|
38
src/Element/MutedList.tsx
Normal file
38
src/Element/MutedList.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { HexKey } from "Nostr"; import type { RootState } from "State/Store";
|
||||
import MuteButton from "Element/MuteButton";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useMutedFeed, { getMuted } from "Feed/MuteList";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
export interface MutedListProps {
|
||||
pubkey: HexKey
|
||||
}
|
||||
|
||||
export default function MutedList({ pubkey }: MutedListProps) {
|
||||
const { muted, isMuted, mute, unmute, muteAll } = useModeration();
|
||||
const feed = useMutedFeed(pubkey)
|
||||
const pubkeys = useMemo(() => {
|
||||
return getMuted(feed.store, pubkey);
|
||||
}, [feed, pubkey]);
|
||||
const hasAllMuted = pubkeys.every(isMuted)
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="flex mt10">
|
||||
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
|
||||
<button
|
||||
disabled={hasAllMuted || pubkeys.length === 0}
|
||||
className="transparent" type="button" onClick={() => muteAll(pubkeys)}
|
||||
>
|
||||
Mute all
|
||||
</button>
|
||||
</div>
|
||||
{pubkeys?.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -181,3 +181,16 @@
|
||||
.light .note.active>.footer>.reaction-pill.reacted {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.hidden-note .header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card.note.hidden-note {
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.hidden-note button {
|
||||
max-height: 30px;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import "./Note.css";
|
||||
import { useCallback, useMemo, ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState, ReactNode } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
@ -12,12 +12,14 @@ import EventKind from "Nostr/EventKind";
|
||||
import useProfile from "Feed/ProfileFeed";
|
||||
import { TaggedRawEvent, u256 } from "Nostr";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
export interface NoteProps {
|
||||
data?: TaggedRawEvent,
|
||||
isThread?: boolean,
|
||||
related: TaggedRawEvent[],
|
||||
highlight?: boolean,
|
||||
ignoreModeration?: boolean,
|
||||
options?: {
|
||||
showHeader?: boolean,
|
||||
showTime?: boolean,
|
||||
@ -26,13 +28,32 @@ export interface NoteProps {
|
||||
["data-ev"]?: NEvent
|
||||
}
|
||||
|
||||
const HiddenNote = ({ children }: any) => {
|
||||
const [show, setShow] = useState(false)
|
||||
return show ? children : (
|
||||
<div className="card note hidden-note">
|
||||
<div className="header">
|
||||
<p>
|
||||
This note was hidden because of your moderation settings
|
||||
</p>
|
||||
<button onClick={() => setShow(true)}>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function Note(props: NoteProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props
|
||||
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props
|
||||
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||
const users = useProfile(pubKeys);
|
||||
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
|
||||
const { isMuted } = useModeration()
|
||||
const isOpMuted = isMuted(ev.PubKey)
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
|
||||
const options = {
|
||||
@ -150,9 +171,11 @@ export default function Note(props: NoteProps) {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}>
|
||||
{content()}
|
||||
</div>
|
||||
const note = (
|
||||
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}>
|
||||
{content()}
|
||||
</div>
|
||||
)
|
||||
|
||||
return !ignoreModeration && isOpMuted ? <HiddenNote>{note}</HiddenNote> : note
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { faTrash, faRepeat, faShareNodes, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash, faBan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
|
||||
@ -20,6 +20,7 @@ import { RootState } from "State/Store";
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { UserPreferences } from "State/Login";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
export interface NoteFooterProps {
|
||||
related: TaggedRawEvent[],
|
||||
@ -30,6 +31,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { related, ev } = props;
|
||||
|
||||
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const { mute, block } = useModeration();
|
||||
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
||||
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey);
|
||||
const publisher = useEventPublisher();
|
||||
@ -169,6 +171,14 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
Copy ID
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => mute(ev.PubKey)}>
|
||||
<FontAwesomeIcon icon={faCommentSlash} />
|
||||
Mute
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => block(ev.PubKey)}>
|
||||
<FontAwesomeIcon icon={faBan} />
|
||||
Block
|
||||
</MenuItem>
|
||||
{prefs.showDebugMenus && (
|
||||
<MenuItem onClick={() => copyEvent()}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
|
@ -9,6 +9,7 @@ import { default as NEvent } from "Nostr/Event";
|
||||
import { eventLink, hexToBech32 } from "Util";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import { RawEvent, TaggedRawEvent } from "Nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
export interface NoteReactionProps {
|
||||
data?: TaggedRawEvent,
|
||||
@ -18,6 +19,7 @@ export interface NoteReactionProps {
|
||||
export default function NoteReaction(props: NoteReactionProps) {
|
||||
const { ["data-ev"]: dataEv, data } = props;
|
||||
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv])
|
||||
const { isMuted } = useModeration();
|
||||
|
||||
const refEvent = useMemo(() => {
|
||||
if (ev) {
|
||||
@ -49,12 +51,13 @@ export default function NoteReaction(props: NoteReactionProps) {
|
||||
}
|
||||
|
||||
const root = extractRoot();
|
||||
const isOpMuted = root && isMuted(root.pubkey)
|
||||
const opt = {
|
||||
showHeader: ev?.Kind === EventKind.Repost,
|
||||
showFooter: false,
|
||||
};
|
||||
|
||||
return (
|
||||
return isOpMuted ? null : (
|
||||
<div className="reaction">
|
||||
<div className="header flex">
|
||||
<ProfileImage pubkey={ev.RootPubKey} />
|
||||
|
@ -69,3 +69,10 @@
|
||||
width: -webkit-fill-available;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.text blockquote {
|
||||
margin: 0;
|
||||
color: var(--font-secondary-color);
|
||||
border-left: 2px solid var(--font-secondary-color);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
@ -1,44 +1,48 @@
|
||||
import "./Timeline.css";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faForward } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
|
||||
import { TaggedRawEvent } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import LoadMore from "Element/LoadMore";
|
||||
import Note from "Element/Note";
|
||||
import NoteReaction from "Element/NoteReaction";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faForward } from "@fortawesome/free-solid-svg-icons";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
export interface TimelineProps {
|
||||
postsOnly: boolean,
|
||||
subject: TimelineSubject,
|
||||
method: "TIME_RANGE" | "LIMIT_UNTIL"
|
||||
ignoreModeration?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of notes by pubkeys
|
||||
*/
|
||||
export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) {
|
||||
export default function Timeline({ subject, postsOnly = false, method, ignoreModeration = false }: TimelineProps) {
|
||||
const { muted, isMuted } = useModeration();
|
||||
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
|
||||
method
|
||||
});
|
||||
|
||||
const filterPosts = useCallback((nts: TaggedRawEvent[]) => {
|
||||
return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true);
|
||||
}, [postsOnly]);
|
||||
return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => ignoreModeration || !isMuted(a.pubkey));
|
||||
}, [postsOnly, muted]);
|
||||
|
||||
const mainFeed = useMemo(() => {
|
||||
return filterPosts(main.notes);
|
||||
}, [main, filterPosts]);
|
||||
|
||||
const latestFeed = useMemo(() => {
|
||||
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id));
|
||||
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id))
|
||||
}, [latest, mainFeed, filterPosts]);
|
||||
|
||||
function eventElement(e: TaggedRawEvent) {
|
||||
switch (e.kind) {
|
||||
case EventKind.TextNote: {
|
||||
return <Note key={e.id} data={e} related={related.notes} />
|
||||
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
|
||||
}
|
||||
case EventKind.Reaction:
|
||||
case EventKind.Repost: {
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { System } from "Nostr/System";
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import Tag from "Nostr/Tag";
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, RawEvent, u256, UserMetadata } from "Nostr";
|
||||
import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
|
||||
import { bech32ToHex } from "Util"
|
||||
import { DefaultRelays, HashtagRegex } from "Const";
|
||||
|
||||
@ -95,6 +96,28 @@ export default function useEventPublisher() {
|
||||
}
|
||||
}
|
||||
},
|
||||
muted: async (keys: HexKey[], priv: HexKey[]) => {
|
||||
if (pubKey) {
|
||||
let ev = NEvent.ForPubKey(pubKey);
|
||||
ev.Kind = EventKind.Lists;
|
||||
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length))
|
||||
keys.forEach(p => {
|
||||
ev.Tags.push(new Tag(["p", p], ev.Tags.length))
|
||||
})
|
||||
let content = ""
|
||||
if (priv.length > 0) {
|
||||
const ps = priv.map(p => ["p", p])
|
||||
const plaintext = JSON.stringify(ps)
|
||||
if (hasNip07 && !privKey) {
|
||||
content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
|
||||
} else if (privKey) {
|
||||
content = await ev.EncryptData(plaintext, pubKey, privKey)
|
||||
}
|
||||
}
|
||||
ev.Content = content;
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
metadata: async (obj: UserMetadata) => {
|
||||
if (pubKey) {
|
||||
let ev = NEvent.ForPubKey(pubKey);
|
||||
@ -279,7 +302,7 @@ const delay = (t: number) => {
|
||||
});
|
||||
}
|
||||
|
||||
const barierNip07 = async (then: () => Promise<any>) => {
|
||||
export const barierNip07 = async (then: () => Promise<any>) => {
|
||||
while (isNip07Busy) {
|
||||
await delay(10);
|
||||
}
|
||||
@ -289,4 +312,4 @@ const barierNip07 = async (then: () => Promise<any>) => {
|
||||
} finally {
|
||||
isNip07Busy = false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -1,24 +1,27 @@
|
||||
import Nostrich from "nostrich.jpg";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import { makeNotification } from "Notifications";
|
||||
import { TaggedRawEvent, HexKey, Lists } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import Event from "Nostr/Event";
|
||||
import { Subscriptions } from "Nostr/Subscriptions";
|
||||
import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login";
|
||||
import { RootState } from "State/Store";
|
||||
import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login";
|
||||
import type { RootState } from "State/Store";
|
||||
import { db } from "Db";
|
||||
import { barierNip07 } from "Feed/EventPublisher";
|
||||
import useSubscription from "Feed/Subscription";
|
||||
import { getMutedKeys, getNewest } from "Feed/MuteList";
|
||||
import { mapEventToProfile, MetadataCache } from "Db/User";
|
||||
import { getDisplayName } from "Element/ProfileImage";
|
||||
import { MentionRegex } from "Const";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
/**
|
||||
* Managed loading data for the current logged in user
|
||||
*/
|
||||
export default function useLoginFeed() {
|
||||
const dispatch = useDispatch();
|
||||
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]);
|
||||
const { publicKey: pubKey, privateKey: privKey } = useSelector((s: RootState) => s.login);
|
||||
const { isMuted } = useModeration();
|
||||
|
||||
const subMetadata = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
@ -27,6 +30,7 @@ export default function useLoginFeed() {
|
||||
sub.Id = `login:meta`;
|
||||
sub.Authors = new Set([pubKey]);
|
||||
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
|
||||
sub.Limit = 2
|
||||
|
||||
return sub;
|
||||
}, [pubKey]);
|
||||
@ -42,6 +46,19 @@ export default function useLoginFeed() {
|
||||
return sub;
|
||||
}, [pubKey]);
|
||||
|
||||
const subMuted = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
||||
let sub = new Subscriptions();
|
||||
sub.Id = "login:muted";
|
||||
sub.Kinds = new Set([EventKind.Lists]);
|
||||
sub.Authors = new Set([pubKey]);
|
||||
sub.DTag = Lists.Muted;
|
||||
sub.Limit = 1;
|
||||
|
||||
return sub;
|
||||
}, [pubKey]);
|
||||
|
||||
const subDms = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
||||
@ -61,6 +78,7 @@ export default function useLoginFeed() {
|
||||
const metadataFeed = useSubscription(subMetadata, { leaveOpen: true });
|
||||
const notificationFeed = useSubscription(subNotification, { leaveOpen: true });
|
||||
const dmsFeed = useSubscription(subDms, { leaveOpen: true });
|
||||
const mutedFeed = useSubscription(subMuted, { leaveOpen: true });
|
||||
|
||||
useEffect(() => {
|
||||
let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
|
||||
@ -75,7 +93,7 @@ export default function useLoginFeed() {
|
||||
dispatch(setRelays({ relays, createdAt: cl.created_at }));
|
||||
}
|
||||
let pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
|
||||
dispatch(setFollows(pTags));
|
||||
dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
@ -85,7 +103,7 @@ export default function useLoginFeed() {
|
||||
acc.created = v.created;
|
||||
}
|
||||
return acc;
|
||||
}, { created: 0, profile: <MetadataCache | null>null });
|
||||
}, { created: 0, profile: null as MetadataCache | null });
|
||||
if (maxProfile.profile) {
|
||||
let existing = await db.users.get(maxProfile.profile.pubkey);
|
||||
if ((existing?.created ?? 0) < maxProfile.created) {
|
||||
@ -93,70 +111,52 @@ export default function useLoginFeed() {
|
||||
}
|
||||
}
|
||||
})().catch(console.warn);
|
||||
}, [metadataFeed.store]);
|
||||
}, [dispatch, metadataFeed.store]);
|
||||
|
||||
useEffect(() => {
|
||||
let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote);
|
||||
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) {
|
||||
sendNotification(nx)
|
||||
.catch(console.warn);
|
||||
const replies = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey))
|
||||
replies.forEach(nx => {
|
||||
makeNotification(nx).then(notification => {
|
||||
if (notification) {
|
||||
// @ts-ignore
|
||||
dispatch(sendNotification(notification))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [dispatch, notificationFeed.store]);
|
||||
|
||||
dispatch(addNotifications(notifications));
|
||||
}, [notificationFeed.store]);
|
||||
useEffect(() => {
|
||||
const muted = getMutedKeys(mutedFeed.store.notes)
|
||||
dispatch(setMuted(muted))
|
||||
|
||||
const newest = getNewest(mutedFeed.store.notes)
|
||||
if (newest && newest.content.length > 0 && pubKey) {
|
||||
decryptBlocked(newest, pubKey, privKey).then((plaintext) => {
|
||||
try {
|
||||
const blocked = JSON.parse(plaintext)
|
||||
const keys = blocked.filter((p:any) => p && p.length === 2 && p[0] === "p").map((p: any) => p[1])
|
||||
dispatch(setBlocked({
|
||||
keys,
|
||||
createdAt: newest.created_at,
|
||||
}))
|
||||
} catch(error) {
|
||||
console.debug("Couldn't parse JSON")
|
||||
}
|
||||
}).catch((error) => console.warn(error))
|
||||
}
|
||||
}, [dispatch, mutedFeed.store])
|
||||
|
||||
useEffect(() => {
|
||||
let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
|
||||
dispatch(addDirectMessage(dms));
|
||||
}, [dmsFeed.store]);
|
||||
}, [dispatch, dmsFeed.store]);
|
||||
}
|
||||
|
||||
async function makeNotification(ev: TaggedRawEvent) {
|
||||
switch (ev.kind) {
|
||||
case EventKind.TextNote: {
|
||||
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]);
|
||||
const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!);
|
||||
const fromUser = users.find(a => a?.pubkey === ev.pubkey);
|
||||
const name = getDisplayName(fromUser, ev.pubkey);
|
||||
const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;
|
||||
return {
|
||||
title: `Reply from ${name}`,
|
||||
body: replaceTagsWithUser(ev, users).substring(0, 50),
|
||||
icon: avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
|
||||
const ev = new Event(raw)
|
||||
if (pubKey && privKey) {
|
||||
return await ev.DecryptData(raw.content, privKey, pubKey)
|
||||
} else {
|
||||
return await barierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
|
||||
}
|
||||
}
|
||||
|
||||
function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) {
|
||||
return ev.content.split(MentionRegex).map(match => {
|
||||
let matchTag = match.match(/#\[(\d+)\]/);
|
||||
if (matchTag && matchTag.length === 2) {
|
||||
let idx = parseInt(matchTag[1]);
|
||||
let ref = ev.tags[idx];
|
||||
if (ref && ref[0] === "p" && ref.length > 1) {
|
||||
let u = users.find(a => a.pubkey === ref[1]);
|
||||
return `@${getDisplayName(u, ref[1])}`;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}).join();
|
||||
}
|
||||
|
||||
async function sendNotification(ev: TaggedRawEvent) {
|
||||
let n = await makeNotification(ev);
|
||||
if (n != null && Notification.permission === "granted") {
|
||||
let worker = await navigator.serviceWorker.ready;
|
||||
worker.showNotification(n.title, {
|
||||
body: n.body,
|
||||
icon: n.icon,
|
||||
tag: "notification",
|
||||
timestamp: ev.created_at * 1000,
|
||||
vibrate: [500]
|
||||
});
|
||||
}
|
||||
}
|
46
src/Feed/MuteList.ts
Normal file
46
src/Feed/MuteList.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { HexKey, TaggedRawEvent, Lists } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { Subscriptions } from "Nostr/Subscriptions";
|
||||
import useSubscription, { NoteStore } from "Feed/Subscription";
|
||||
|
||||
export default function useMutedFeed(pubkey: HexKey) {
|
||||
const sub = useMemo(() => {
|
||||
let sub = new Subscriptions();
|
||||
sub.Id = `muted:${pubkey.slice(0, 12)}`;
|
||||
sub.Kinds = new Set([EventKind.Lists]);
|
||||
sub.Authors = new Set([pubkey]);
|
||||
sub.DTag = Lists.Muted;
|
||||
sub.Limit = 1;
|
||||
return sub;
|
||||
}, [pubkey]);
|
||||
|
||||
return useSubscription(sub);
|
||||
}
|
||||
|
||||
export function getNewest(rawNotes: TaggedRawEvent[]){
|
||||
const notes = [...rawNotes]
|
||||
notes.sort((a, b) => a.created_at - b.created_at)
|
||||
if (notes.length > 0) {
|
||||
return notes[0]
|
||||
}
|
||||
}
|
||||
|
||||
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } {
|
||||
const newest = getNewest(rawNotes)
|
||||
if (newest) {
|
||||
const { created_at, tags } = newest
|
||||
const keys = tags.filter(t => t[0] === "p").map(t => t[1])
|
||||
return {
|
||||
keys,
|
||||
createdAt: created_at,
|
||||
}
|
||||
}
|
||||
return { createdAt: 0, keys: [] }
|
||||
}
|
||||
|
||||
export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
|
||||
let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey);
|
||||
return getMutedKeys(lists).keys;
|
||||
}
|
78
src/Hooks/useModeration.tsx
Normal file
78
src/Hooks/useModeration.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
|
||||
import type { RootState } from "State/Store";
|
||||
import { HexKey } from "Nostr";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { setMuted, setBlocked } from "State/Login";
|
||||
|
||||
|
||||
export default function useModeration() {
|
||||
const dispatch = useDispatch()
|
||||
const { blocked, muted } = useSelector((s: RootState) => s.login)
|
||||
const publisher = useEventPublisher()
|
||||
|
||||
async function setMutedList(pub: HexKey[], priv: HexKey[]) {
|
||||
try {
|
||||
const ev = await publisher.muted(pub, priv)
|
||||
console.debug(ev);
|
||||
publisher.broadcast(ev)
|
||||
} catch (error) {
|
||||
console.debug("Couldn't change mute list")
|
||||
}
|
||||
}
|
||||
|
||||
function isMuted(id: HexKey) {
|
||||
return muted.includes(id) || blocked.includes(id)
|
||||
}
|
||||
|
||||
function isBlocked(id: HexKey) {
|
||||
return blocked.includes(id)
|
||||
}
|
||||
|
||||
function unmute(id: HexKey) {
|
||||
const newMuted = muted.filter(p => p !== id)
|
||||
dispatch(setMuted({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newMuted
|
||||
}))
|
||||
setMutedList(newMuted, blocked)
|
||||
}
|
||||
|
||||
function unblock(id: HexKey) {
|
||||
const newBlocked = blocked.filter(p => p !== id)
|
||||
dispatch(setBlocked({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newBlocked
|
||||
}))
|
||||
setMutedList(muted, newBlocked)
|
||||
}
|
||||
|
||||
function mute(id: HexKey) {
|
||||
const newMuted = muted.includes(id) ? muted : muted.concat([id])
|
||||
setMutedList(newMuted, blocked)
|
||||
dispatch(setMuted({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newMuted
|
||||
}))
|
||||
}
|
||||
|
||||
function block(id: HexKey) {
|
||||
const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id])
|
||||
setMutedList(muted, newBlocked)
|
||||
dispatch(setBlocked({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newBlocked
|
||||
}))
|
||||
}
|
||||
|
||||
function muteAll(ids: HexKey[]) {
|
||||
const newMuted = Array.from(new Set(muted.concat(ids)))
|
||||
setMutedList(newMuted, blocked)
|
||||
dispatch(setMuted({
|
||||
createdAt: new Date().getTime(),
|
||||
keys: newMuted
|
||||
}))
|
||||
}
|
||||
|
||||
return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked }
|
||||
}
|
@ -139,26 +139,33 @@ export default class Event {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the message content in place
|
||||
* Encrypt the given message content
|
||||
*/
|
||||
async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) {
|
||||
async EncryptData(content: string, pubkey: HexKey, privkey: HexKey) {
|
||||
let key = await this._GetDmSharedKey(pubkey, privkey);
|
||||
let iv = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
let data = new TextEncoder().encode(this.Content);
|
||||
let data = new TextEncoder().encode(content);
|
||||
let result = await window.crypto.subtle.encrypt({
|
||||
name: "AES-CBC",
|
||||
iv: iv
|
||||
}, key, data);
|
||||
let uData = new Uint8Array(result);
|
||||
this.Content = `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`;
|
||||
return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the content of this message in place
|
||||
* Encrypt the message content in place
|
||||
*/
|
||||
async DecryptDm(privkey: HexKey, pubkey: HexKey) {
|
||||
async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) {
|
||||
this.Content = await this.EncryptData(this.Content, pubkey, privkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the content of the message
|
||||
*/
|
||||
async DecryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) {
|
||||
let key = await this._GetDmSharedKey(pubkey, privkey);
|
||||
let cSplit = this.Content.split("?iv=");
|
||||
let cSplit = cyphertext.split("?iv=");
|
||||
let data = new Uint8Array(base64.length(cSplit[0]));
|
||||
base64.decode(cSplit[0], data, 0);
|
||||
|
||||
@ -169,7 +176,14 @@ export default class Event {
|
||||
name: "AES-CBC",
|
||||
iv: iv
|
||||
}, key, data);
|
||||
this.Content = new TextDecoder().decode(result);
|
||||
return new TextDecoder().decode(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the content of this message in place
|
||||
*/
|
||||
async DecryptDm(privkey: HexKey, pubkey: HexKey) {
|
||||
this.Content = await this.DecryptData(this.Content, privkey, pubkey)
|
||||
}
|
||||
|
||||
async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) {
|
||||
@ -177,4 +191,4 @@ export default class Event {
|
||||
let sharedX = sharedPoint.slice(1, 33);
|
||||
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,8 @@ const enum EventKind {
|
||||
DirectMessage = 4, // NIP-04
|
||||
Deletion = 5, // NIP-09
|
||||
Repost = 6, // NIP-18
|
||||
Reaction = 7 // NIP-25
|
||||
Reaction = 7, // NIP-25
|
||||
Lists = 30000, // NIP-51
|
||||
};
|
||||
|
||||
export default EventKind;
|
@ -42,6 +42,11 @@ export class Subscriptions {
|
||||
*/
|
||||
HashTags?: Set<string>;
|
||||
|
||||
/**
|
||||
* A "d" tag to search
|
||||
*/
|
||||
DTag?: string;
|
||||
|
||||
/**
|
||||
* a timestamp, events must be newer than this to pass
|
||||
*/
|
||||
@ -89,6 +94,7 @@ export class Subscriptions {
|
||||
this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined;
|
||||
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
|
||||
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
|
||||
this.DTag = sub?.["#d"] ? sub["#d"] : undefined;
|
||||
this.Since = sub?.since ?? undefined;
|
||||
this.Until = sub?.until ?? undefined;
|
||||
this.Limit = sub?.limit ?? undefined;
|
||||
@ -130,9 +136,12 @@ export class Subscriptions {
|
||||
if (this.PTags) {
|
||||
ret["#p"] = Array.from(this.PTags);
|
||||
}
|
||||
if(this.HashTags) {
|
||||
if (this.HashTags) {
|
||||
ret["#t"] = Array.from(this.HashTags);
|
||||
}
|
||||
if (this.DTag) {
|
||||
ret["#d"] = this.DTag;
|
||||
}
|
||||
if (this.Since !== null) {
|
||||
ret.since = this.Since;
|
||||
}
|
||||
@ -144,4 +153,4 @@ export class Subscriptions {
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ export default class Tag {
|
||||
Relay?: string;
|
||||
Marker?: string;
|
||||
Hashtag?: string;
|
||||
DTag?: string;
|
||||
Index: number;
|
||||
Invalid: boolean;
|
||||
|
||||
@ -36,6 +37,10 @@ export default class Tag {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "d": {
|
||||
this.DTag = tag[1];
|
||||
break;
|
||||
}
|
||||
case "t": {
|
||||
this.Hashtag = tag[1];
|
||||
break;
|
||||
@ -61,9 +66,12 @@ export default class Tag {
|
||||
case "t": {
|
||||
return ["t", this.Hashtag!];
|
||||
}
|
||||
case "d": {
|
||||
return ["d", this.DTag!];
|
||||
}
|
||||
default: {
|
||||
return this.Original;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ export type RawReqFilter = {
|
||||
"#e"?: u256[],
|
||||
"#p"?: u256[],
|
||||
"#t"?: string[],
|
||||
"#d"?: string,
|
||||
since?: number,
|
||||
until?: number,
|
||||
limit?: number
|
||||
@ -53,4 +54,11 @@ export type UserMetadata = {
|
||||
nip05?: string,
|
||||
lud06?: string,
|
||||
lud16?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-51 list types
|
||||
*/
|
||||
export enum Lists {
|
||||
Muted = "mute"
|
||||
}
|
||||
|
43
src/Notifications.ts
Normal file
43
src/Notifications.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import Nostrich from "nostrich.jpg";
|
||||
|
||||
import { TaggedRawEvent } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import type { NotificationRequest } from "State/Login";
|
||||
import { db } from "Db";
|
||||
import { MetadataCache } from "Db/User";
|
||||
import { getDisplayName } from "Element/ProfileImage";
|
||||
import { MentionRegex } from "Const";
|
||||
|
||||
export async function makeNotification(ev: TaggedRawEvent): Promise<NotificationRequest | null> {
|
||||
switch (ev.kind) {
|
||||
case EventKind.TextNote: {
|
||||
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]);
|
||||
const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!);
|
||||
const fromUser = users.find(a => a?.pubkey === ev.pubkey);
|
||||
const name = getDisplayName(fromUser, ev.pubkey);
|
||||
const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;
|
||||
return {
|
||||
title: `Reply from ${name}`,
|
||||
body: replaceTagsWithUser(ev, users).substring(0, 50),
|
||||
icon: avatarUrl,
|
||||
timestamp: ev.created_at * 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) {
|
||||
return ev.content.split(MentionRegex).map(match => {
|
||||
let matchTag = match.match(/#\[(\d+)\]/);
|
||||
if (matchTag && matchTag.length === 2) {
|
||||
let idx = parseInt(matchTag[1]);
|
||||
let ref = ev.tags[idx];
|
||||
if (ref && ref[0] === "p" && ref.length > 1) {
|
||||
let u = users.find(a => a.pubkey === ref[1]);
|
||||
return `@${getDisplayName(u, ref[1])}`;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
}).join();
|
||||
}
|
@ -20,7 +20,7 @@ export default function LoginPage() {
|
||||
if (publicKey) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [publicKey]);
|
||||
}, [publicKey, navigate]);
|
||||
|
||||
async function getNip05PubKey(addr: string) {
|
||||
let [username, domain] = addr.split("@");
|
||||
@ -32,7 +32,7 @@ export default function LoginPage() {
|
||||
return pKey;
|
||||
}
|
||||
}
|
||||
throw "User key not found"
|
||||
throw new Error("User key not found")
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
@ -43,7 +43,7 @@ export default function LoginPage() {
|
||||
if (secp.utils.isValidPrivateKey(hexKey)) {
|
||||
dispatch(setPrivateKey(hexKey));
|
||||
} else {
|
||||
throw "INVALID PRIVATE KEY";
|
||||
throw new Error("INVALID PRIVATE KEY");
|
||||
}
|
||||
} else if (key.startsWith("npub")) {
|
||||
let hexKey = bech32ToHex(key);
|
||||
@ -55,7 +55,7 @@ export default function LoginPage() {
|
||||
if (secp.utils.isValidPrivateKey(key)) {
|
||||
dispatch(setPrivateKey(key));
|
||||
} else {
|
||||
throw "INVALID PRIVATE KEY";
|
||||
throw new Error("INVALID PRIVATE KEY");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -8,6 +8,7 @@ import { hexToBech32 } from "../Util";
|
||||
import { incDmInteraction } from "State/Login";
|
||||
import { RootState } from "State/Store";
|
||||
import NoteToSelf from "Element/NoteToSelf";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
type DmChat = {
|
||||
pubkey: HexKey,
|
||||
@ -20,9 +21,10 @@ export default function MessagesPage() {
|
||||
const myPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
|
||||
const dmInteraction = useSelector<RootState, number>(s => s.login.dmInteraction);
|
||||
const { isMuted } = useModeration();
|
||||
|
||||
const chats = useMemo(() => {
|
||||
return extractChats(dms, myPubKey!);
|
||||
return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!)
|
||||
}, [dms, myPubKey, dmInteraction]);
|
||||
|
||||
function noteToSelf(chat: DmChat) {
|
||||
@ -91,7 +93,7 @@ export function isToSelf(e: RawEvent, pk: HexKey) {
|
||||
}
|
||||
|
||||
export function dmsInChat(dms: RawEvent[], pk: HexKey) {
|
||||
return dms.filter(a => a.pubkey === pk || dmTo(a) == pk);
|
||||
return dms.filter(a => a.pubkey === pk || dmTo(a) === pk);
|
||||
}
|
||||
|
||||
export function totalUnread(dms: RawEvent[], myPubKey: HexKey) {
|
||||
|
@ -10,6 +10,7 @@ import useProfile from "Feed/ProfileFeed";
|
||||
import FollowButton from "Element/FollowButton";
|
||||
import { extractLnAddress, parseId, hexToBech32 } from "Util";
|
||||
import Avatar from "Element/Avatar";
|
||||
import LogoutButton from "Element/LogoutButton";
|
||||
import Timeline from "Element/Timeline";
|
||||
import Text from 'Element/Text'
|
||||
import LNURLTip from "Element/LNURLTip";
|
||||
@ -17,6 +18,8 @@ import Nip05 from "Element/Nip05";
|
||||
import Copy from "Element/Copy";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import FollowersList from "Element/FollowersList";
|
||||
import BlockList from "Element/BlockList";
|
||||
import MutedList from "Element/MutedList";
|
||||
import FollowsList from "Element/FollowsList";
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey } from "Nostr";
|
||||
@ -26,7 +29,9 @@ enum ProfileTab {
|
||||
Notes = "Notes",
|
||||
Reactions = "Reactions",
|
||||
Followers = "Followers",
|
||||
Follows = "Follows"
|
||||
Follows = "Follows",
|
||||
Muted = "Muted",
|
||||
Blocked = "Blocked"
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
@ -103,7 +108,7 @@ export default function ProfilePage() {
|
||||
function tabContent() {
|
||||
switch (tab) {
|
||||
case ProfileTab.Notes:
|
||||
return <Timeline key={id} subject={{ type: "pubkey", items: [id] }} postsOnly={false} method={"LIMIT_UNTIL"} />;
|
||||
return <Timeline key={id} subject={{ type: "pubkey", items: [id] }} postsOnly={false} method={"LIMIT_UNTIL"} ignoreModeration={true} />;
|
||||
case ProfileTab.Follows: {
|
||||
if (isMe) {
|
||||
return (
|
||||
@ -119,6 +124,12 @@ export default function ProfilePage() {
|
||||
case ProfileTab.Followers: {
|
||||
return <FollowersList pubkey={id} />
|
||||
}
|
||||
case ProfileTab.Muted: {
|
||||
return isMe ? <BlockList variant="muted" /> : <MutedList pubkey={id} />
|
||||
}
|
||||
case ProfileTab.Blocked: {
|
||||
return isMe ? <BlockList variant="blocked" /> : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -136,9 +147,12 @@ export default function ProfilePage() {
|
||||
{username()}
|
||||
<div className="profile-actions">
|
||||
{isMe ? (
|
||||
<>
|
||||
<LogoutButton />
|
||||
<button type="button" onClick={() => navigate("/settings")}>
|
||||
Settings
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
!loggedOut && (
|
||||
<>
|
||||
@ -155,6 +169,10 @@ export default function ProfilePage() {
|
||||
)
|
||||
}
|
||||
|
||||
function renderTab(v: ProfileTab) {
|
||||
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="profile flex">
|
||||
@ -165,9 +183,8 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabs">
|
||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => {
|
||||
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
|
||||
})}
|
||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)}
|
||||
{isMe && renderTab(ProfileTab.Blocked)}
|
||||
</div>
|
||||
{tabContent()}
|
||||
</>
|
||||
|
@ -1,12 +1,23 @@
|
||||
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";
|
||||
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { faRightFromBracket, faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { logout } from "State/Login";
|
||||
|
||||
const SettingsIndex = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleLogout() {
|
||||
dispatch(logout())
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-nav">
|
||||
<div className="card" onClick={() => navigate("profile")}>
|
||||
<FontAwesomeIcon icon={faUser} size="xl" className="mr10" />
|
||||
@ -24,7 +35,12 @@ const SettingsIndex = () => {
|
||||
<FontAwesomeIcon icon={faCircleDollarToSlot} size="xl" className="mr10" />
|
||||
Donate
|
||||
</div>
|
||||
<div className="card" onClick={handleLogout}>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} size="xl" className="mr10" />
|
||||
Log Out
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@ import "./Profile.css";
|
||||
import Nostrich from "nostrich.jpg";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faShop } from "@fortawesome/free-solid-svg-icons";
|
||||
@ -10,7 +10,7 @@ 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 } from "State/Login";
|
||||
import LogoutButton from "Element/LogoutButton";
|
||||
import { hexToBech32, openFile } from "Util";
|
||||
import Copy from "Element/Copy";
|
||||
import { RootState } from "State/Store";
|
||||
@ -20,7 +20,6 @@ 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 dispatch = useDispatch();
|
||||
const user = useProfile(id)?.get(id || "");
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
@ -143,7 +142,7 @@ export default function ProfileSettings() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div>
|
||||
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</button>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" onClick={() => saveProfile()}>Save</button>
|
||||
|
@ -3,12 +3,20 @@ import * as secp from '@noble/secp256k1';
|
||||
import { DefaultRelays } from 'Const';
|
||||
import { HexKey, TaggedRawEvent } from 'Nostr';
|
||||
import { RelaySettings } from 'Nostr/Connection';
|
||||
import type { AppDispatch, RootState } from "State/Store";
|
||||
|
||||
const PrivateKeyItem = "secret";
|
||||
const PublicKeyItem = "pubkey";
|
||||
const NotificationsReadItem = "notifications-read";
|
||||
const UserPreferencesKey = "preferences";
|
||||
|
||||
export interface NotificationRequest {
|
||||
title: string
|
||||
body: string
|
||||
icon: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
/**
|
||||
* Enable reactions / reposts / zaps
|
||||
@ -72,6 +80,26 @@ export interface LoginStore {
|
||||
*/
|
||||
follows: HexKey[],
|
||||
|
||||
/**
|
||||
* Newest relay list timestamp
|
||||
*/
|
||||
latestFollows: number,
|
||||
|
||||
/**
|
||||
* A list of pubkeys this user has muted
|
||||
*/
|
||||
muted: HexKey[],
|
||||
|
||||
/**
|
||||
* Last seen mute list event timestamp
|
||||
*/
|
||||
latestMuted: number,
|
||||
|
||||
/**
|
||||
* A list of pubkeys this user has muted privately
|
||||
*/
|
||||
blocked: HexKey[],
|
||||
|
||||
/**
|
||||
* Notifications for this login session
|
||||
*/
|
||||
@ -105,6 +133,10 @@ const InitState = {
|
||||
relays: {},
|
||||
latestRelays: 0,
|
||||
follows: [],
|
||||
latestFollows: 0,
|
||||
muted: [],
|
||||
blocked: [],
|
||||
latestMuted: 0,
|
||||
notifications: [],
|
||||
readNotifications: new Date().getTime(),
|
||||
dms: [],
|
||||
@ -124,6 +156,11 @@ export interface SetRelaysPayload {
|
||||
createdAt: number
|
||||
};
|
||||
|
||||
export interface SetFollowsPayload {
|
||||
keys: HexKey[]
|
||||
createdAt: number
|
||||
};
|
||||
|
||||
const LoginSlice = createSlice({
|
||||
name: "Login",
|
||||
initialState: InitState,
|
||||
@ -192,9 +229,14 @@ const LoginSlice = createSlice({
|
||||
delete state.relays[action.payload];
|
||||
state.relays = { ...state.relays };
|
||||
},
|
||||
setFollows: (state, action: PayloadAction<string | string[]>) => {
|
||||
setFollows: (state, action: PayloadAction<SetFollowsPayload>) => {
|
||||
const { keys, createdAt } = action.payload
|
||||
if (state.latestFollows > createdAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing = new Set(state.follows);
|
||||
let update = Array.isArray(action.payload) ? action.payload : [action.payload];
|
||||
let update = Array.isArray(keys) ? keys : [keys];
|
||||
|
||||
let changes = false;
|
||||
for (let pk of update) {
|
||||
@ -205,26 +247,24 @@ const LoginSlice = createSlice({
|
||||
}
|
||||
if (changes) {
|
||||
state.follows = Array.from(existing);
|
||||
state.latestFollows = createdAt;
|
||||
}
|
||||
},
|
||||
addNotifications: (state, action: PayloadAction<TaggedRawEvent | TaggedRawEvent[]>) => {
|
||||
let n = action.payload;
|
||||
if (!Array.isArray(n)) {
|
||||
n = [n];
|
||||
}
|
||||
|
||||
let didChange = false;
|
||||
for (let x of n) {
|
||||
if (!state.notifications.some(a => a.id === x.id)) {
|
||||
state.notifications.push(x);
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
if (didChange) {
|
||||
state.notifications = [
|
||||
...state.notifications
|
||||
];
|
||||
}
|
||||
setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) {
|
||||
const { createdAt, keys } = action.payload
|
||||
if (createdAt >= state.latestMuted) {
|
||||
const muted = new Set([...keys])
|
||||
state.muted = Array.from(muted)
|
||||
state.latestMuted = createdAt
|
||||
}
|
||||
},
|
||||
setBlocked(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) {
|
||||
const { createdAt, keys } = action.payload
|
||||
if (createdAt >= state.latestMuted) {
|
||||
const blocked = new Set([...keys])
|
||||
state.blocked = Array.from(blocked)
|
||||
state.latestMuted = createdAt
|
||||
}
|
||||
},
|
||||
addDirectMessage: (state, action: PayloadAction<TaggedRawEvent | Array<TaggedRawEvent>>) => {
|
||||
let n = action.payload;
|
||||
@ -239,6 +279,7 @@ const LoginSlice = createSlice({
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didChange) {
|
||||
state.dms = [
|
||||
...state.dms
|
||||
@ -272,11 +313,36 @@ export const {
|
||||
setRelays,
|
||||
removeRelay,
|
||||
setFollows,
|
||||
addNotifications,
|
||||
setMuted,
|
||||
setBlocked,
|
||||
addDirectMessage,
|
||||
incDmInteraction,
|
||||
logout,
|
||||
markNotificationsRead,
|
||||
setPreferences
|
||||
setPreferences,
|
||||
} = LoginSlice.actions;
|
||||
export const reducer = LoginSlice.reducer;
|
||||
|
||||
export function sendNotification({ title, body, icon, timestamp }: NotificationRequest) {
|
||||
return async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState()
|
||||
const { readNotifications } = state.login
|
||||
const hasPermission = "Notification" in window && Notification.permission === "granted"
|
||||
const shouldShowNotification = hasPermission && timestamp > readNotifications
|
||||
if (shouldShowNotification) {
|
||||
try {
|
||||
let worker = await navigator.serviceWorker.ready;
|
||||
worker.showNotification(title, {
|
||||
tag: "notification",
|
||||
vibrate: [500],
|
||||
body,
|
||||
icon,
|
||||
timestamp,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reducer = LoginSlice.reducer;
|
||||
|
@ -124,10 +124,15 @@ button:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--gray);
|
||||
}
|
||||
.light button:disabled {
|
||||
|
||||
.light button.transparent {
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.light button:disabled {
|
||||
color: var(--font-secondary-color);
|
||||
border-color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--font-color);
|
||||
@ -392,27 +397,6 @@ body.scroll-lock {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabs>div {
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs>div:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs .active {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
}
|
||||
@ -426,12 +410,27 @@ body.scroll-lock {
|
||||
}
|
||||
|
||||
.tabs {
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
display: flex;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tabs > * {
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs > *:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs .active {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border-bottom: 1px solid var(--gray-secondary);
|
||||
font-weight: 700;
|
||||
|
Loading…
x
Reference in New Issue
Block a user