diff --git a/public/index.html b/public/index.html index c5af8453..e4560f15 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,7 @@ + content="default-src 'self'; child-src 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self'; img-src * data:; font-src https://fonts.gstatic.com; media-src *;" /> diff --git a/src/element/Note.css b/src/element/Note.css index 955a8d61..1b6bdc3b 100644 --- a/src/element/Note.css +++ b/src/element/Note.css @@ -3,11 +3,6 @@ border-bottom: 1px solid #333; } -.note > .header { - display: flex; - align-items: center; -} - .note > .header > .pfp { flex-grow: 1; } diff --git a/src/element/Note.js b/src/element/Note.js index 7fa2a2a5..d984e9b8 100644 --- a/src/element/Note.js +++ b/src/element/Note.js @@ -18,6 +18,7 @@ const MentionRegex = /(#\[\d+\])/gi; export default function Note(props) { const navigate = useNavigate(); const data = props.data; + const opt = props.options; const dataEvent = props["data-ev"]; const reactions = props.reactions; const publisher = useEventPublisher(); @@ -25,6 +26,13 @@ export default function Note(props) { const users = useSelector(s => s.users?.users); const ev = dataEvent ?? Event.FromObject(data); + const options = { + showHeader: true, + showTime: true, + showFooter: true, + ...opt + }; + function goToEvent(e, id) { if (!window.location.pathname.startsWith("/e/")) { e.stopPropagation(); @@ -121,28 +129,31 @@ export default function Note(props) { return (
-
- -
- {moment(ev.CreatedAt * 1000).fromNow()} -
-
+ {options.showHeader ? +
+ + {options.showTime ? +
+ {moment(ev.CreatedAt * 1000).fromNow()} +
: null} +
: null}
goToEvent(e, ev.Id)}> {transformBody()}
-
- setShowReply(!showReply)}> - - - like()}> -   - {(reactions?.length ?? 0)} - - console.debug(ev)}> - - -
- {showReply ? setShowReply(false)}/> : null} + {options.showFooter ? +
+ setShowReply(!showReply)}> + + + like()}> +   + {(reactions?.length ?? 0)} + + console.debug(ev)}> + + +
: null} + {showReply ? setShowReply(false)} /> : null}
) } \ No newline at end of file diff --git a/src/element/NoteReaction.css b/src/element/NoteReaction.css new file mode 100644 index 00000000..13984c9b --- /dev/null +++ b/src/element/NoteReaction.css @@ -0,0 +1,23 @@ +.reaction { + margin-bottom: 10px; + border-bottom: 1px solid #333; +} + +.reaction > .note { + margin: 5px; + border: 1px solid #333; + border-radius: 10px; + padding: 5px; +} + +.reaction > .header > .pfp { + flex-grow: 1; +} + +.reaction > .header .reply { + font-size: small; +} + +.reaction > .header > .info { + font-size: small; +} \ No newline at end of file diff --git a/src/element/NoteReaction.js b/src/element/NoteReaction.js new file mode 100644 index 00000000..3317ecfd --- /dev/null +++ b/src/element/NoteReaction.js @@ -0,0 +1,40 @@ +import "./NoteReaction.css"; +import moment from "moment"; +import EventKind from "../nostr/EventKind"; +import Note from "./Note"; +import ProfileImage from "./ProfileImage"; + +export default function NoteReaction(props) { + const data = props.data; + const root = props.root; + + if (data.kind !== EventKind.Reaction) { + return null; + } + + function mapReaction() { + switch (data.content) { + case "+": return "❤️"; + case "-": return "👎"; + default: { + if (data.content.length === 0) { + return "❤️"; + } + return data.content; + } + } + } + + return ( +
+
+ Reacted with {mapReaction()}} /> +
+ {moment(data.created_at * 1000).fromNow()} +
+
+ + {root ? : root} +
+ ); +} \ No newline at end of file diff --git a/src/element/ProfileImage.js b/src/element/ProfileImage.js index 976d2125..4688af11 100644 --- a/src/element/ProfileImage.js +++ b/src/element/ProfileImage.js @@ -4,18 +4,18 @@ import useProfile from "../feed/ProfileFeed"; import Nostrich from "../nostrich.jpg"; export default function ProfileImage(props) { - const pubKey = props.pubkey; + const pubkey = props.pubkey; const subHeader = props.subHeader; const navigate = useNavigate(); - const user = useProfile(pubKey); + const user = useProfile(pubkey); const hasImage = (user?.picture?.length ?? 0) > 0; return (
- navigate(`/p/${pubKey}`)} /> + navigate(`/p/${pubkey}`)} />
- {user?.name ?? pubKey.substring(0, 8)} - {subHeader} + {user?.name ?? pubkey.substring(0, 8)} + {subHeader ?
{subHeader}
: null}
) diff --git a/src/feed/ThreadFeed.js b/src/feed/ThreadFeed.js index 8c1119b4..d8a65985 100644 --- a/src/feed/ThreadFeed.js +++ b/src/feed/ThreadFeed.js @@ -6,7 +6,7 @@ import useSubscription from "./Subscription"; export default function useThreadFeed(id) { const sub = useMemo(() => { const thisSub = new Subscriptions(); - thisSub.Id = `thread:${thisSub.Id}`; + thisSub.Id = `thread:${id.substring(0, 8)}`; thisSub.Ids.add(id); // get replies to this event @@ -26,7 +26,7 @@ export default function useThreadFeed(id) { if (thisNote) { let otherSubs = new Subscriptions(); - otherSubs.Id = `thread-related:${otherSubs.Id}`; + otherSubs.Id = `thread-related:${id.substring(0, 8)}`; for (let e of thisNote.tags.filter(a => a[0] === "e")) { otherSubs.Ids.add(e[1]); } @@ -47,8 +47,10 @@ export default function useThreadFeed(id) { const others = useSubscription(relatedThisSub, { leaveOpen: true }); - return { - main: main.notes, - other: others.notes - }; + return useMemo(() => { + return { + main: main.notes, + other: others.notes, + }; + }, [main, others]); } \ No newline at end of file diff --git a/src/pages/EventPage.js b/src/pages/EventPage.js index 35a44b02..6975094c 100644 --- a/src/pages/EventPage.js +++ b/src/pages/EventPage.js @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useParams } from "react-router-dom"; import Thread from "../element/Thread"; import useThreadFeed from "../feed/ThreadFeed"; @@ -6,9 +7,14 @@ export default function EventPage() { const params = useParams(); const id = params.id; - const { main, other } = useThreadFeed(id); - return a.indexOf(v) === i)} this={id} />; + const thread = useThreadFeed(id); + + const filtered = useMemo(() => { + return [ + ...thread.main, + ...thread.other + ].filter((v, i, a) => a.findIndex(x => x.id === v.id) === i); + }, [thread]); + + return ; } \ No newline at end of file diff --git a/src/pages/Layout.js b/src/pages/Layout.js index 59801d69..b13fb6c8 100644 --- a/src/pages/Layout.js +++ b/src/pages/Layout.js @@ -17,6 +17,7 @@ export default function Layout(props) { const key = useSelector(s => s.login.publicKey); const relays = useSelector(s => s.login.relays); const notifications = useSelector(s => s.login.notifications); + const readNotifications = useSelector(s => s.login.readNotifications); useUsersCache(); useLoginFeed(); @@ -33,11 +34,12 @@ export default function Layout(props) { }, []); function accountHeader() { + const unreadNotifications = notifications?.filter(a => a.created_at > readNotifications).length ?? 0; return ( <>
navigate("/notifications")}> - {notifications?.length ?? 0} + {unreadNotifications}
diff --git a/src/pages/Notifications.js b/src/pages/Notifications.js index d27c1ed8..40aa322b 100644 --- a/src/pages/Notifications.js +++ b/src/pages/Notifications.js @@ -1,6 +1,57 @@ +import { useEffect, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux" +import Note from "../element/Note"; +import NoteReaction from "../element/NoteReaction"; +import useSubscription from "../feed/Subscription"; +import EventKind from "../nostr/EventKind"; +import { Subscriptions } from "../nostr/Subscriptions"; +import { markNotificationsRead } from "../state/Login"; + export default function NotificationsPage() { + const dispatch = useDispatch(); + const notifications = useSelector(s => s.login.notifications); + + useEffect(() => { + dispatch(markNotificationsRead()); + }, []); + + const etagged = useMemo(() => { + return notifications?.filter(a => a.kind === EventKind.Reaction) + .map(a => a.tags.filter(b => b[0] === "e")[0][1]) + }, [notifications]); + + const subEvents = useMemo(() => { + let sub = new Subscriptions(); + sub.Id = `reactions:${sub.Id}`; + sub.Kinds.add(EventKind.TextNote); + sub.Ids = new Set(etagged); + + let replyReactions = new Subscriptions(); + replyReactions.Kinds.add(EventKind.Reaction); + replyReactions.ETags = new Set(notifications?.filter(b => b.kind === EventKind.TextNote).map(b => b.id)); + sub.OrSubs.push(replyReactions); + return sub; + }, [etagged]); + + const otherNotes = useSubscription(subEvents, { leaveOpen: true }); + + const sorted = [ + ...notifications + ].sort((a,b) => b.created_at - a.created_at); + return ( <> + {sorted?.map(a => { + if (a.kind === EventKind.TextNote) { + let reactions = otherNotes?.notes?.filter(c => c.tags.find(b => b[0] === "e" && b[1] === a.id)); + return + } else if (a.kind === EventKind.Reaction) { + let reactedTo = a.tags.filter(a => a[0] === "e")[0][1]; + let reactedNote = otherNotes?.notes?.find(c => c.id === reactedTo); + return + } + return null; + })} ) } \ No newline at end of file diff --git a/src/state/Login.js b/src/state/Login.js index 603181e0..b122fee5 100644 --- a/src/state/Login.js +++ b/src/state/Login.js @@ -3,6 +3,7 @@ import * as secp from '@noble/secp256k1'; const PrivateKeyItem = "secret"; const Nip07PublicKeyItem = "nip07:pubkey"; +const NotificationsReadItem = "notifications-read"; const LoginSlice = createSlice({ name: "Login", @@ -35,7 +36,12 @@ const LoginSlice = createSlice({ /** * Notifications for this login session */ - notifications: [] + notifications: [], + + /** + * Timestamp of last read notification + */ + readNotifications: 0, }, reducers: { init: (state) => { @@ -56,6 +62,12 @@ const LoginSlice = createSlice({ state.publicKey = nip07PubKey; state.nip07 = true; } + + // notifications + let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem)); + if (!isNaN(readNotif)) { + state.readNotifications = readNotif; + } }, setPrivateKey: (state, action) => { state.privateKey = action.payload; @@ -86,7 +98,7 @@ const LoginSlice = createSlice({ n = [n]; } - for (let x in n) { + for (let x of n) { if (!state.notifications.some(a => a.id === x.id)) { state.notifications.push(x); } @@ -102,9 +114,13 @@ const LoginSlice = createSlice({ state.publicKey = null; state.follows = []; state.notifications = []; + }, + markNotificationsRead: (state) => { + state.readNotifications = new Date().getTime(); + window.localStorage.setItem(NotificationsReadItem, state.readNotifications); } } }); -export const { init, setPrivateKey, setPublicKey, setNip07PubKey, setRelays, setFollows, addNotifications, logout } = LoginSlice.actions; +export const { init, setPrivateKey, setPublicKey, setNip07PubKey, setRelays, setFollows, addNotifications, logout, markNotificationsRead } = LoginSlice.actions; export const reducer = LoginSlice.reducer; \ No newline at end of file