Notifications page

This commit is contained in:
Kieran 2023-01-02 11:15:13 +00:00
parent d2ed1178ed
commit 1d9c25686b
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
11 changed files with 191 additions and 45 deletions

View File

@ -8,7 +8,7 @@
<meta name="theme-color" content="#000000" />
<meta name="description" content="Fast nostr web ui" />
<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="manifest" href="%PUBLIC_URL%/manifest.json" />

View File

@ -3,11 +3,6 @@
border-bottom: 1px solid #333;
}
.note > .header {
display: flex;
align-items: center;
}
.note > .header > .pfp {
flex-grow: 1;
}

View File

@ -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 (
<div className="note">
<div className="header">
<ProfileImage pubkey={ev.PubKey} subHeader={replyTag()} />
<div className="info">
{moment(ev.CreatedAt * 1000).fromNow()}
</div>
</div>
{options.showHeader ?
<div className="header flex">
<ProfileImage pubkey={ev.PubKey} subHeader={replyTag()} />
{options.showTime ?
<div className="info">
{moment(ev.CreatedAt * 1000).fromNow()}
</div> : null}
</div> : null}
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
</div>
<div className="footer">
<span className="pill" onClick={() => setShowReply(!showReply)}>
<FontAwesomeIcon icon={faReply} />
</span>
<span className="pill" onClick={() => like()}>
<FontAwesomeIcon icon={faHeart} /> &nbsp;
{(reactions?.length ?? 0)}
</span>
<span className="pill" onClick={() => console.debug(ev)}>
<FontAwesomeIcon icon={faInfo} />
</span>
</div>
{showReply ? <NoteCreator replyTo={ev} onSend={() => setShowReply(false)}/> : null}
{options.showFooter ?
<div className="footer">
<span className="pill" onClick={() => setShowReply(!showReply)}>
<FontAwesomeIcon icon={faReply} />
</span>
<span className="pill" onClick={() => like()}>
<FontAwesomeIcon icon={faHeart} /> &nbsp;
{(reactions?.length ?? 0)}
</span>
<span className="pill" onClick={() => console.debug(ev)}>
<FontAwesomeIcon icon={faInfo} />
</span>
</div> : null}
{showReply ? <NoteCreator replyTo={ev} onSend={() => setShowReply(false)} /> : null}
</div>
)
}

View 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;
}

View 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>
);
}

View File

@ -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 (
<div className="pfp">
<img src={hasImage ? user.picture : Nostrich} onClick={() => navigate(`/p/${pubKey}`)} />
<img src={hasImage ? user.picture : Nostrich} onClick={() => navigate(`/p/${pubkey}`)} />
<div>
{user?.name ?? pubKey.substring(0, 8)}
{subHeader}
{user?.name ?? pubkey.substring(0, 8)}
{subHeader ? <div>{subHeader}</div> : null}
</div>
</div>
)

View File

@ -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]);
}

View File

@ -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 <Thread notes={[
...main,
...other
].filter((v, i, a) => 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 <Thread notes={filtered} this={id} />;
}

View File

@ -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 (
<>
<div className="btn btn-rnd notifications" onClick={() => navigate("/notifications")}>
<FontAwesomeIcon icon={faBell} size="xl" />
{notifications?.length ?? 0}
{unreadNotifications}
</div>
<ProfileImage pubkey={key} />
</>

View File

@ -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 <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;
})}
</>
)
}

View File

@ -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;