Notifications page
This commit is contained in:
parent
d2ed1178ed
commit
1d9c25686b
@ -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" />
|
||||
|
@ -3,11 +3,6 @@
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.note > .header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note > .header > .pfp {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
@ -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} />
|
||||
{(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} />
|
||||
{(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>
|
||||
)
|
||||
}
|
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";
|
||||
|
||||
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>
|
||||
)
|
||||
|
@ -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]);
|
||||
}
|
@ -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} />;
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
|
@ -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;
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
@ -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;
|
Loading…
x
Reference in New Issue
Block a user