Notifications page
This commit is contained in:
parent
d2ed1178ed
commit
1d9c25686b
@ -8,7 +8,7 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="description" content="Fast nostr web ui" />
|
<meta name="description" content="Fast nostr web ui" />
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
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;" />
|
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 *;" />
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
@ -3,11 +3,6 @@
|
|||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note > .header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note > .header > .pfp {
|
.note > .header > .pfp {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ const MentionRegex = /(#\[\d+\])/gi;
|
|||||||
export default function Note(props) {
|
export default function Note(props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const data = props.data;
|
const data = props.data;
|
||||||
|
const opt = props.options;
|
||||||
const dataEvent = props["data-ev"];
|
const dataEvent = props["data-ev"];
|
||||||
const reactions = props.reactions;
|
const reactions = props.reactions;
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
@ -25,6 +26,13 @@ export default function Note(props) {
|
|||||||
const users = useSelector(s => s.users?.users);
|
const users = useSelector(s => s.users?.users);
|
||||||
const ev = dataEvent ?? Event.FromObject(data);
|
const ev = dataEvent ?? Event.FromObject(data);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
showHeader: true,
|
||||||
|
showTime: true,
|
||||||
|
showFooter: true,
|
||||||
|
...opt
|
||||||
|
};
|
||||||
|
|
||||||
function goToEvent(e, id) {
|
function goToEvent(e, id) {
|
||||||
if (!window.location.pathname.startsWith("/e/")) {
|
if (!window.location.pathname.startsWith("/e/")) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -121,15 +129,18 @@ export default function Note(props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="note">
|
<div className="note">
|
||||||
<div className="header">
|
{options.showHeader ?
|
||||||
|
<div className="header flex">
|
||||||
<ProfileImage pubkey={ev.PubKey} subHeader={replyTag()} />
|
<ProfileImage pubkey={ev.PubKey} subHeader={replyTag()} />
|
||||||
|
{options.showTime ?
|
||||||
<div className="info">
|
<div className="info">
|
||||||
{moment(ev.CreatedAt * 1000).fromNow()}
|
{moment(ev.CreatedAt * 1000).fromNow()}
|
||||||
</div>
|
</div> : null}
|
||||||
</div>
|
</div> : null}
|
||||||
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
|
||||||
{transformBody()}
|
{transformBody()}
|
||||||
</div>
|
</div>
|
||||||
|
{options.showFooter ?
|
||||||
<div className="footer">
|
<div className="footer">
|
||||||
<span className="pill" onClick={() => setShowReply(!showReply)}>
|
<span className="pill" onClick={() => setShowReply(!showReply)}>
|
||||||
<FontAwesomeIcon icon={faReply} />
|
<FontAwesomeIcon icon={faReply} />
|
||||||
@ -141,8 +152,8 @@ export default function Note(props) {
|
|||||||
<span className="pill" onClick={() => console.debug(ev)}>
|
<span className="pill" onClick={() => console.debug(ev)}>
|
||||||
<FontAwesomeIcon icon={faInfo} />
|
<FontAwesomeIcon icon={faInfo} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div> : null}
|
||||||
{showReply ? <NoteCreator replyTo={ev} onSend={() => setShowReply(false)}/> : null}
|
{showReply ? <NoteCreator replyTo={ev} onSend={() => setShowReply(false)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
23
src/element/NoteReaction.css
Normal file
23
src/element/NoteReaction.css
Normal file
@ -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;
|
||||||
|
}
|
40
src/element/NoteReaction.js
Normal file
40
src/element/NoteReaction.js
Normal file
@ -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 (
|
||||||
|
<div className="reaction">
|
||||||
|
<div className="header flex">
|
||||||
|
<ProfileImage pubkey={data.pubkey} subHeader={<small>Reacted with {mapReaction()}</small>} />
|
||||||
|
<div className="info">
|
||||||
|
{moment(data.created_at * 1000).fromNow()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{root ? <Note data={root} options={{ showHeader: false, showFooter: false }} /> : root}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -4,18 +4,18 @@ import useProfile from "../feed/ProfileFeed";
|
|||||||
import Nostrich from "../nostrich.jpg";
|
import Nostrich from "../nostrich.jpg";
|
||||||
|
|
||||||
export default function ProfileImage(props) {
|
export default function ProfileImage(props) {
|
||||||
const pubKey = props.pubkey;
|
const pubkey = props.pubkey;
|
||||||
const subHeader = props.subHeader;
|
const subHeader = props.subHeader;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useProfile(pubKey);
|
const user = useProfile(pubkey);
|
||||||
|
|
||||||
const hasImage = (user?.picture?.length ?? 0) > 0;
|
const hasImage = (user?.picture?.length ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<div className="pfp">
|
<div className="pfp">
|
||||||
<img src={hasImage ? user.picture : Nostrich} onClick={() => navigate(`/p/${pubKey}`)} />
|
<img src={hasImage ? user.picture : Nostrich} onClick={() => navigate(`/p/${pubkey}`)} />
|
||||||
<div>
|
<div>
|
||||||
{user?.name ?? pubKey.substring(0, 8)}
|
{user?.name ?? pubkey.substring(0, 8)}
|
||||||
{subHeader}
|
{subHeader ? <div>{subHeader}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -6,7 +6,7 @@ import useSubscription from "./Subscription";
|
|||||||
export default function useThreadFeed(id) {
|
export default function useThreadFeed(id) {
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const thisSub = new Subscriptions();
|
const thisSub = new Subscriptions();
|
||||||
thisSub.Id = `thread:${thisSub.Id}`;
|
thisSub.Id = `thread:${id.substring(0, 8)}`;
|
||||||
thisSub.Ids.add(id);
|
thisSub.Ids.add(id);
|
||||||
|
|
||||||
// get replies to this event
|
// get replies to this event
|
||||||
@ -26,7 +26,7 @@ export default function useThreadFeed(id) {
|
|||||||
|
|
||||||
if (thisNote) {
|
if (thisNote) {
|
||||||
let otherSubs = new Subscriptions();
|
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")) {
|
for (let e of thisNote.tags.filter(a => a[0] === "e")) {
|
||||||
otherSubs.Ids.add(e[1]);
|
otherSubs.Ids.add(e[1]);
|
||||||
}
|
}
|
||||||
@ -47,8 +47,10 @@ export default function useThreadFeed(id) {
|
|||||||
|
|
||||||
const others = useSubscription(relatedThisSub, { leaveOpen: true });
|
const others = useSubscription(relatedThisSub, { leaveOpen: true });
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
return {
|
return {
|
||||||
main: main.notes,
|
main: main.notes,
|
||||||
other: others.notes
|
other: others.notes,
|
||||||
};
|
};
|
||||||
|
}, [main, others]);
|
||||||
}
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import Thread from "../element/Thread";
|
import Thread from "../element/Thread";
|
||||||
import useThreadFeed from "../feed/ThreadFeed";
|
import useThreadFeed from "../feed/ThreadFeed";
|
||||||
@ -6,9 +7,14 @@ export default function EventPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
|
|
||||||
const { main, other } = useThreadFeed(id);
|
const thread = useThreadFeed(id);
|
||||||
return <Thread notes={[
|
|
||||||
...main,
|
const filtered = useMemo(() => {
|
||||||
...other
|
return [
|
||||||
].filter((v, i, a) => a.indexOf(v) === i)} this={id} />;
|
...thread.main,
|
||||||
|
...thread.other
|
||||||
|
].filter((v, i, a) => a.findIndex(x => x.id === v.id) === i);
|
||||||
|
}, [thread]);
|
||||||
|
|
||||||
|
return <Thread notes={filtered} this={id} />;
|
||||||
}
|
}
|
@ -17,6 +17,7 @@ export default function Layout(props) {
|
|||||||
const key = useSelector(s => s.login.publicKey);
|
const key = useSelector(s => s.login.publicKey);
|
||||||
const relays = useSelector(s => s.login.relays);
|
const relays = useSelector(s => s.login.relays);
|
||||||
const notifications = useSelector(s => s.login.notifications);
|
const notifications = useSelector(s => s.login.notifications);
|
||||||
|
const readNotifications = useSelector(s => s.login.readNotifications);
|
||||||
useUsersCache();
|
useUsersCache();
|
||||||
useLoginFeed();
|
useLoginFeed();
|
||||||
|
|
||||||
@ -33,11 +34,12 @@ export default function Layout(props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function accountHeader() {
|
function accountHeader() {
|
||||||
|
const unreadNotifications = notifications?.filter(a => a.created_at > readNotifications).length ?? 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="btn btn-rnd notifications" onClick={() => navigate("/notifications")}>
|
<div className="btn btn-rnd notifications" onClick={() => navigate("/notifications")}>
|
||||||
<FontAwesomeIcon icon={faBell} size="xl" />
|
<FontAwesomeIcon icon={faBell} size="xl" />
|
||||||
{notifications?.length ?? 0}
|
{unreadNotifications}
|
||||||
</div>
|
</div>
|
||||||
<ProfileImage pubkey={key} />
|
<ProfileImage pubkey={key} />
|
||||||
</>
|
</>
|
||||||
|
@ -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() {
|
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 (
|
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 <Note data={a} key={a.id} reactions={reactions}/>
|
||||||
|
} 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 <NoteReaction data={a} key={a.id} root={reactedNote} />
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -3,6 +3,7 @@ import * as secp from '@noble/secp256k1';
|
|||||||
|
|
||||||
const PrivateKeyItem = "secret";
|
const PrivateKeyItem = "secret";
|
||||||
const Nip07PublicKeyItem = "nip07:pubkey";
|
const Nip07PublicKeyItem = "nip07:pubkey";
|
||||||
|
const NotificationsReadItem = "notifications-read";
|
||||||
|
|
||||||
const LoginSlice = createSlice({
|
const LoginSlice = createSlice({
|
||||||
name: "Login",
|
name: "Login",
|
||||||
@ -35,7 +36,12 @@ const LoginSlice = createSlice({
|
|||||||
/**
|
/**
|
||||||
* Notifications for this login session
|
* Notifications for this login session
|
||||||
*/
|
*/
|
||||||
notifications: []
|
notifications: [],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp of last read notification
|
||||||
|
*/
|
||||||
|
readNotifications: 0,
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
init: (state) => {
|
init: (state) => {
|
||||||
@ -56,6 +62,12 @@ const LoginSlice = createSlice({
|
|||||||
state.publicKey = nip07PubKey;
|
state.publicKey = nip07PubKey;
|
||||||
state.nip07 = true;
|
state.nip07 = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifications
|
||||||
|
let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem));
|
||||||
|
if (!isNaN(readNotif)) {
|
||||||
|
state.readNotifications = readNotif;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setPrivateKey: (state, action) => {
|
setPrivateKey: (state, action) => {
|
||||||
state.privateKey = action.payload;
|
state.privateKey = action.payload;
|
||||||
@ -86,7 +98,7 @@ const LoginSlice = createSlice({
|
|||||||
n = [n];
|
n = [n];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let x in n) {
|
for (let x of n) {
|
||||||
if (!state.notifications.some(a => a.id === x.id)) {
|
if (!state.notifications.some(a => a.id === x.id)) {
|
||||||
state.notifications.push(x);
|
state.notifications.push(x);
|
||||||
}
|
}
|
||||||
@ -102,9 +114,13 @@ const LoginSlice = createSlice({
|
|||||||
state.publicKey = null;
|
state.publicKey = null;
|
||||||
state.follows = [];
|
state.follows = [];
|
||||||
state.notifications = [];
|
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;
|
export const reducer = LoginSlice.reducer;
|
Loading…
Reference in New Issue
Block a user