From ee6bd38fdfd20c31a8fd410da1d4e4945fe7426c Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 10 Feb 2023 11:07:01 +0100 Subject: [PATCH 01/11] feat: pinned notes and bookmarks --- public/index.html | 2 +- src/Element/Bookmarks.tsx | 77 ++++++++++++++++++++++++++++++++++++++ src/Element/Note.css | 30 +++++++++++++++ src/Element/Note.tsx | 48 ++++++++++++++++++++++-- src/Element/NoteFooter.tsx | 34 ++++++++++++++++- src/Element/messages.ts | 7 ++++ src/Feed/BookmarkFeed.tsx | 64 +++++++++++++++++++++++++++++++ src/Feed/EventPublisher.ts | 26 ++++++++++++- src/Feed/LoginFeed.ts | 61 +++++++++++++++++++++++++++++- src/Feed/MuteList.ts | 13 ++----- src/Feed/PinnedFeed.tsx | 64 +++++++++++++++++++++++++++++++ src/Icons/Bookmark.tsx | 17 +++++++++ src/Icons/Pin.tsx | 14 +++++++ src/Nostr/EventKind.ts | 4 +- src/Nostr/index.ts | 3 ++ src/Pages/ProfilePage.css | 21 +++++++++++ src/Pages/ProfilePage.tsx | 55 ++++++++++++++++++++------- src/Pages/messages.ts | 2 + src/State/Login.ts | 42 +++++++++++++++++++++ src/Util.ts | 8 ++++ src/lang.json | 18 +++++++++ src/translations/en.json | 2 + 22 files changed, 578 insertions(+), 34 deletions(-) create mode 100644 src/Element/Bookmarks.tsx create mode 100644 src/Feed/BookmarkFeed.tsx create mode 100644 src/Feed/PinnedFeed.tsx create mode 100644 src/Icons/Bookmark.tsx create mode 100644 src/Icons/Pin.tsx diff --git a/public/index.html b/public/index.html index 88b149a4..82d3c1a8 100644 --- a/public/index.html +++ b/public/index.html @@ -11,7 +11,7 @@ content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" /> - + snort.social - Nostr interface diff --git a/src/Element/Bookmarks.tsx b/src/Element/Bookmarks.tsx new file mode 100644 index 00000000..50cf4f3c --- /dev/null +++ b/src/Element/Bookmarks.tsx @@ -0,0 +1,77 @@ +import { useState, useMemo, ChangeEvent } from "react"; +import { useSelector } from "react-redux"; +import { FormattedMessage } from "react-intl"; + +import Note from "Element/Note"; +import Bookmark from "Icons/Bookmark"; +import { HexKey, TaggedRawEvent } from "Nostr"; +import { useUserProfiles } from "Feed/ProfileFeed"; +import { RootState } from "State/Store"; + +import messages from "./messages"; + +interface BookmarksProps { + pubkey: HexKey; + bookmarks: TaggedRawEvent[]; + related: TaggedRawEvent[]; +} + +const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => { + const [onlyPubkey, setOnlyPubkey] = useState("all"); + const loginPubKey = useSelector((s: RootState) => s.login.publicKey); + const ps = useMemo(() => { + return bookmarks.reduce( + ({ ps, seen }, e) => { + if (seen.has(e.pubkey)) { + return { ps, seen }; + } + seen.add(e.pubkey); + return { + seen, + ps: [...ps, e.pubkey], + }; + }, + { ps: [] as HexKey[], seen: new Set() } + ).ps; + }, [bookmarks]); + const profiles = useUserProfiles(ps); + + function renderOption(p: HexKey) { + const profile = profiles?.get(p); + return profile ? : null; + } + + return ( +
+
+ {" "} +

+ +

+ +
+ {bookmarks + .filter(b => (onlyPubkey === "all" ? true : b.pubkey === onlyPubkey)) + .map(n => { + return ( + + ); + })} +
+ ); +}; + +export default Bookmarks; diff --git a/src/Element/Note.css b/src/Element/Note.css index 2be012a4..53b379c9 100644 --- a/src/Element/Note.css +++ b/src/Element/Note.css @@ -20,6 +20,36 @@ margin-left: 4px; white-space: nowrap; color: var(--font-secondary-color); + display: flex; + align-items: center; +} + +.note > .header > .info .saved { + margin-right: 12px; + font-weight: 600; + font-size: 10px; + line-height: 12px; + letter-spacing: 0.11em; + text-transform: uppercase; + display: flex; + align-items: center; +} +.note > .header > .info .saved svg { + margin-right: 8px; +} + +.note > .header > .pinned { + font-size: var(--font-size-small); + color: var(--font-secondary-color); + font-weight: 500; + line-height: 22px; + display: flex; + flex-direction: row; + align-items: center; +} + +.note > .header > .pinned svg { + margin-right: 8px; } .note > .body { diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 084bf258..33af85ee 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -1,20 +1,25 @@ import "./Note.css"; import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; +import { useSelector, useDispatch } from "react-redux"; import { useInView } from "react-intersection-observer"; import { useIntl, FormattedMessage } from "react-intl"; +import useEventPublisher from "Feed/EventPublisher"; +import Bookmark from "Icons/Bookmark"; +import Pin from "Icons/Pin"; import { default as NEvent } from "Nostr/Event"; import ProfileImage from "Element/ProfileImage"; import Text from "Element/Text"; - import { eventLink, getReactions, hexToBech32 } from "Util"; import NoteFooter, { Translation } from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; import EventKind from "Nostr/EventKind"; import { useUserProfiles } from "Feed/ProfileFeed"; -import { TaggedRawEvent, u256 } from "Nostr"; +import { TaggedRawEvent, u256, HexKey } from "Nostr"; import useModeration from "Hooks/useModeration"; +import { setPinned, setBookmarked } from "State/Login"; +import type { RootState } from "State/Store"; import messages from "./messages"; @@ -27,7 +32,11 @@ export interface NoteProps { options?: { showHeader?: boolean; showTime?: boolean; + showPinned?: boolean; + showBookmarked?: boolean; showFooter?: boolean; + canUnpin?: boolean; + canUnbookmark?: boolean; }; ["data-ev"]?: NEvent; } @@ -52,6 +61,7 @@ const HiddenNote = ({ children }: { children: React.ReactNode }) => { export default function Note(props: NoteProps) { const navigate = useNavigate(); + const dispatch = useDispatch(); const { data, 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]); @@ -63,6 +73,8 @@ export default function Note(props: NoteProps) { const [extendable, setExtendable] = useState(false); const [showMore, setShowMore] = useState(false); const baseClassName = `note card ${props.className ? props.className : ""}`; + const { pinned, bookmarked } = useSelector((s: RootState) => s.login); + const publisher = useEventPublisher(); const [translated, setTranslated] = useState(); const { formatMessage } = useIntl(); @@ -70,9 +82,29 @@ export default function Note(props: NoteProps) { showHeader: true, showTime: true, showFooter: true, + canUnpin: false, + canUnbookmark: false, ...opt, }; + async function unpin(id: HexKey) { + if (options.canUnpin) { + const es = pinned.filter(e => e !== id); + const ev = await publisher.pinned(es); + publisher.broadcast(ev); + dispatch(setPinned({ keys: es, createdAt: new Date().getTime() })); + } + } + + async function unbookmark(id: HexKey) { + if (options.canUnbookmark) { + const es = bookmarked.filter(e => e !== id); + const ev = await publisher.bookmarked(es); + publisher.broadcast(ev); + dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() })); + } + } + const transformBody = useCallback(() => { const body = ev?.Content ?? ""; if (deletions?.length > 0) { @@ -190,11 +222,21 @@ export default function Note(props: NoteProps) { {options.showHeader && (
- {options.showTime && ( + {(options.showTime || options.showBookmarked) && (
+ {options.showBookmarked && ( +
unbookmark(ev.Id)}> + +
+ )}
)} + {options.showPinned && ( +
unpin(ev.Id)}> + +
+ )}
)}
goToEvent(e, ev.Id)}> diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 4b0a7de5..6f999f92 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -1,8 +1,10 @@ import { useMemo, useState } from "react"; -import { useSelector } from "react-redux"; +import { useSelector, useDispatch } from "react-redux"; import { useIntl, FormattedMessage } from "react-intl"; import { Menu, MenuItem } from "@szhsin/react-menu"; +import Bookmark from "Icons/Bookmark"; +import Pin from "Icons/Pin"; import Json from "Icons/Json"; import Repost from "Icons/Repost"; import Trash from "Icons/Trash"; @@ -28,7 +30,7 @@ import { default as NEvent } from "Nostr/Event"; import { RootState } from "State/Store"; import { HexKey, TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; -import { UserPreferences } from "State/Login"; +import { UserPreferences, setPinned, setBookmarked } from "State/Login"; import useModeration from "Hooks/useModeration"; import { TranslateHost } from "Const"; @@ -48,7 +50,9 @@ export interface NoteFooterProps { export default function NoteFooter(props: NoteFooterProps) { const { related, ev } = props; + const dispatch = useDispatch(); const { formatMessage } = useIntl(); + const { pinned, bookmarked } = useSelector((s: RootState) => s.login); const login = useSelector(s => s.login.publicKey); const { mute, block } = useModeration(); const prefs = useSelector(s => s.login.preferences); @@ -213,6 +217,20 @@ export default function NoteFooter(props: NoteFooterProps) { await navigator.clipboard.writeText(hexToBech32("note", ev.Id)); } + async function pin(id: HexKey) { + const es = [...pinned, id]; + const ev = await publisher.pinned(es); + publisher.broadcast(ev); + dispatch(setPinned({ keys: es, createdAt: new Date().getTime() })); + } + + async function bookmark(id: HexKey) { + const es = [...bookmarked, id]; + const ev = await publisher.bookmarked(es); + publisher.broadcast(ev); + dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() })); + } + async function copyEvent() { await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, " ")); } @@ -230,6 +248,18 @@ export default function NoteFooter(props: NoteFooterProps) { + {!pinned.includes(ev.Id) && ( + pin(ev.Id)}> + + + + )} + {!bookmarked.includes(ev.Id) && ( + bookmark(ev.Id)}> + + + + )} copyId()}> diff --git a/src/Element/messages.ts b/src/Element/messages.ts index 852552ba..957c8f67 100644 --- a/src/Element/messages.ts +++ b/src/Element/messages.ts @@ -93,4 +93,11 @@ export default defineMessages({ defaultMessage: "Please make sure to save the following password in order to manage your handle in the future", }, Handle: { defaultMessage: "Handle" }, + Pin: { defaultMessage: "Pin" }, + Pinned: { defaultMessage: "Pinned" }, + Bookmark: { defaultMessage: "Bookmark" }, + Bookmarks: { defaultMessage: "Bookmarks" }, + BookmarksCount: { defaultMessage: "Bookmarks ({n})" }, + Bookmarked: { defaultMessage: "Saved" }, + All: { defaultMessage: "All" }, }); diff --git a/src/Feed/BookmarkFeed.tsx b/src/Feed/BookmarkFeed.tsx new file mode 100644 index 00000000..d63bdc52 --- /dev/null +++ b/src/Feed/BookmarkFeed.tsx @@ -0,0 +1,64 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { getNewest } from "Util"; +import { HexKey, Lists } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import { Subscriptions } from "Nostr/Subscriptions"; +import useSubscription from "Feed/Subscription"; +import { RootState } from "State/Store"; + +export default function useBookmarkFeed(pubkey: HexKey) { + const pref = useSelector((s: RootState) => s.login.preferences); + const { publicKey, bookmarked } = useSelector((s: RootState) => s.login); + const isMe = publicKey === pubkey; + + const sub = useMemo(() => { + if (isMe) return null; + const sub = new Subscriptions(); + sub.Id = `bookmark:${pubkey.slice(0, 12)}`; + sub.Kinds = new Set([EventKind.NoteLists]); + sub.Authors = new Set([pubkey]); + sub.DTags = new Set([Lists.Bookmarked]); + sub.Limit = 1; + return sub; + }, [pubkey]); + + const { store } = useSubscription(sub, { leaveOpen: true, cache: true }); + const etags = useMemo(() => { + if (isMe) return bookmarked; + const newest = getNewest(store.notes); + if (newest) { + const { tags } = newest; + return tags.filter(t => t[0] === "e").map(t => t[1]); + } + return []; + }, [store.notes, isMe]); + + const esub = useMemo(() => { + const s = new Subscriptions(); + s.Id = `bookmark-notes:${pubkey.slice(0, 12)}`; + s.Kinds = new Set([EventKind.TextNote]); + s.Ids = new Set(etags); + return s; + }, [etags]); + + const subRelated = useMemo(() => { + let sub: Subscriptions | undefined; + if (etags.length > 0 && pref.enableReactions) { + sub = new Subscriptions(); + sub.Id = `bookmark-related`; + sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]); + sub.ETags = new Set(etags); + } + return sub ?? null; + }, [etags, pref]); + + const bookmarkSub = useSubscription(esub, { leaveOpen: true, cache: true }); + const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true }); + + const bookmarks = bookmarkSub.store.notes; + const related = relatedSub.store.notes; + + return { bookmarks, related }; +} diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index cbb83b2a..795562cf 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -122,7 +122,7 @@ export default function useEventPublisher() { muted: async (keys: HexKey[], priv: HexKey[]) => { if (pubKey) { const ev = NEvent.ForPubKey(pubKey); - ev.Kind = EventKind.Lists; + ev.Kind = EventKind.PubkeyLists; ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length)); keys.forEach(p => { ev.Tags.push(new Tag(["p", p], ev.Tags.length)); @@ -141,6 +141,30 @@ export default function useEventPublisher() { return await signEvent(ev); } }, + pinned: async (notes: HexKey[]) => { + if (pubKey) { + const ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.NoteLists; + ev.Tags.push(new Tag(["d", Lists.Pinned], ev.Tags.length)); + notes.forEach(n => { + ev.Tags.push(new Tag(["e", n], ev.Tags.length)); + }); + ev.Content = ""; + return await signEvent(ev); + } + }, + bookmarked: async (notes: HexKey[]) => { + if (pubKey) { + const ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.NoteLists; + ev.Tags.push(new Tag(["d", Lists.Bookmarked], ev.Tags.length)); + notes.forEach(n => { + ev.Tags.push(new Tag(["e", n], ev.Tags.length)); + }); + ev.Content = ""; + return await signEvent(ev); + } + }, metadata: async (obj: UserMetadata) => { if (pubKey) { const ev = NEvent.ForPubKey(pubKey); diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 1fe3c459..2e9b1331 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; +import { getNewest } from "Util"; import { makeNotification } from "Notifications"; import { TaggedRawEvent, HexKey, Lists } from "Nostr"; import EventKind from "Nostr/EventKind"; @@ -11,6 +12,8 @@ import { setFollows, setRelays, setMuted, + setPinned, + setBookmarked, setBlocked, sendNotification, setLatestNotifications, @@ -20,7 +23,7 @@ import { mapEventToProfile, MetadataCache } from "State/Users"; import { useDb } from "State/Users/Db"; import useSubscription from "Feed/Subscription"; import { barrierNip07 } from "Feed/EventPublisher"; -import { getMutedKeys, getNewest } from "Feed/MuteList"; +import { getMutedKeys } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; import { unwrap } from "Util"; import { AnyAction, ThunkDispatch } from "@reduxjs/toolkit"; @@ -68,7 +71,7 @@ export default function useLoginFeed() { const sub = new Subscriptions(); sub.Id = "login:muted"; - sub.Kinds = new Set([EventKind.Lists]); + sub.Kinds = new Set([EventKind.PubkeyLists]); sub.Authors = new Set([pubKey]); sub.DTags = new Set([Lists.Muted]); sub.Limit = 1; @@ -76,6 +79,32 @@ export default function useLoginFeed() { return sub; }, [pubKey]); + const subPinned = useMemo(() => { + if (!pubKey) return null; + + const sub = new Subscriptions(); + sub.Id = "login:pinned"; + sub.Kinds = new Set([EventKind.NoteLists]); + sub.Authors = new Set([pubKey]); + sub.DTags = new Set([Lists.Pinned]); + sub.Limit = 1; + + return sub; + }, [pubKey]); + + const subBookmarks = useMemo(() => { + if (!pubKey) return null; + + const sub = new Subscriptions(); + sub.Id = "login:bookmarks"; + sub.Kinds = new Set([EventKind.NoteLists]); + sub.Authors = new Set([pubKey]); + sub.DTags = new Set([Lists.Bookmarked]); + sub.Limit = 1; + + return sub; + }, [pubKey]); + const subDms = useMemo(() => { if (!pubKey) return null; @@ -102,6 +131,8 @@ export default function useLoginFeed() { }); const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true }); const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true }); + const pinnedFeed = useSubscription(subPinned, { leaveOpen: true, cache: true }); + const bookmarkFeed = useSubscription(subBookmarks, { leaveOpen: true, cache: true }); useEffect(() => { const contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList); @@ -179,6 +210,32 @@ export default function useLoginFeed() { } }, [dispatch, mutedFeed.store]); + useEffect(() => { + const newest = getNewest(pinnedFeed.store.notes); + if (newest) { + const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]); + dispatch( + setPinned({ + keys, + createdAt: newest.created_at, + }) + ); + } + }, [dispatch, pinnedFeed.store]); + + useEffect(() => { + const newest = getNewest(bookmarkFeed.store.notes); + if (newest) { + const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === "e").map(p => p[1]); + dispatch( + setBookmarked({ + keys, + createdAt: newest.created_at, + }) + ); + } + }, [dispatch, bookmarkFeed.store]); + useEffect(() => { const dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage); dispatch(addDirectMessage(dms)); diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 6ad2d8f5..d24dbba4 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; +import { getNewest } from "Util"; import { HexKey, TaggedRawEvent, Lists } from "Nostr"; import EventKind from "Nostr/EventKind"; import { Subscriptions } from "Nostr/Subscriptions"; @@ -9,7 +10,7 @@ export default function useMutedFeed(pubkey: HexKey) { const sub = useMemo(() => { const sub = new Subscriptions(); sub.Id = `muted:${pubkey.slice(0, 12)}`; - sub.Kinds = new Set([EventKind.Lists]); + sub.Kinds = new Set([EventKind.PubkeyLists]); sub.Authors = new Set([pubkey]); sub.DTags = new Set([Lists.Muted]); sub.Limit = 1; @@ -19,14 +20,6 @@ export default function useMutedFeed(pubkey: HexKey) { 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[]; @@ -44,6 +37,6 @@ export function getMutedKeys(rawNotes: TaggedRawEvent[]): { } export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] { - const lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey); + const lists = feed?.notes.filter(a => a.kind === EventKind.PubkeyLists && a.pubkey === pubkey); return getMutedKeys(lists).keys; } diff --git a/src/Feed/PinnedFeed.tsx b/src/Feed/PinnedFeed.tsx new file mode 100644 index 00000000..99b6317d --- /dev/null +++ b/src/Feed/PinnedFeed.tsx @@ -0,0 +1,64 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { getNewest } from "Util"; +import { HexKey, Lists } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import { Subscriptions } from "Nostr/Subscriptions"; +import useSubscription from "Feed/Subscription"; +import { RootState } from "State/Store"; + +export default function usePinnedFeed(pubkey: HexKey) { + const pref = useSelector((s: RootState) => s.login.preferences); + const { publicKey, pinned: pinnedNotes } = useSelector((s: RootState) => s.login); + const isMe = publicKey === pubkey; + + const sub = useMemo(() => { + if (isMe) return null; + const sub = new Subscriptions(); + sub.Id = `pinned:${pubkey.slice(0, 12)}`; + sub.Kinds = new Set([EventKind.NoteLists]); + sub.Authors = new Set([pubkey]); + sub.DTags = new Set([Lists.Pinned]); + sub.Limit = 1; + return sub; + }, [pubkey]); + + const { store } = useSubscription(sub, { leaveOpen: true, cache: true }); + const etags = useMemo(() => { + if (isMe) return pinnedNotes; + const newest = getNewest(store.notes); + if (newest) { + const { tags } = newest; + return tags.filter(t => t[0] === "e").map(t => t[1]); + } + return []; + }, [store.notes, isMe]); + + const subRelated = useMemo(() => { + let sub: Subscriptions | undefined; + if (etags.length > 0 && pref.enableReactions) { + sub = new Subscriptions(); + sub.Id = `pinned-related`; + sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]); + sub.ETags = new Set(etags); + } + return sub ?? null; + }, [etags, pref]); + + const esub = useMemo(() => { + const s = new Subscriptions(); + s.Id = `pinned-notes:${pubkey.slice(0, 12)}`; + s.Kinds = new Set([EventKind.TextNote]); + s.Ids = new Set(etags); + return s; + }, [etags]); + + const pinnedSub = useSubscription(esub, { leaveOpen: true, cache: true }); + const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true }); + + const pinned = pinnedSub.store.notes; + const related = relatedSub.store.notes; + + return { pinned, related }; +} diff --git a/src/Icons/Bookmark.tsx b/src/Icons/Bookmark.tsx new file mode 100644 index 00000000..121f4179 --- /dev/null +++ b/src/Icons/Bookmark.tsx @@ -0,0 +1,17 @@ +import IconProps from "./IconProps"; + +const Bookmark = (props: IconProps) => { + return ( + + + + ); +}; + +export default Bookmark; diff --git a/src/Icons/Pin.tsx b/src/Icons/Pin.tsx new file mode 100644 index 00000000..3f3af489 --- /dev/null +++ b/src/Icons/Pin.tsx @@ -0,0 +1,14 @@ +const Pin = () => { + return ( + + + + ); +}; + +export default Pin; diff --git a/src/Nostr/EventKind.ts b/src/Nostr/EventKind.ts index ec16373b..ef7a3351 100644 --- a/src/Nostr/EventKind.ts +++ b/src/Nostr/EventKind.ts @@ -10,7 +10,9 @@ const enum EventKind { Reaction = 7, // NIP-25 Relays = 10002, // NIP-65 Auth = 22242, // NIP-42 - Lists = 30000, // NIP-51 + PubkeyLists = 30000, // NIP-51 + NoteLists = 30001, // NIP-51 + TagLists = 30002, // NIP-51 ZapRequest = 9734, // NIP tba ZapReceipt = 9735, // NIP tba } diff --git a/src/Nostr/index.ts b/src/Nostr/index.ts index f3949914..f961b280 100644 --- a/src/Nostr/index.ts +++ b/src/Nostr/index.ts @@ -68,6 +68,9 @@ export type UserMetadata = { */ export enum Lists { Muted = "mute", + Pinned = "pin", + Bookmarked = "bookmark", + Followed = "follow", } export interface RelaySettings { diff --git a/src/Pages/ProfilePage.css b/src/Pages/ProfilePage.css index feb8f7ac..2718ce43 100644 --- a/src/Pages/ProfilePage.css +++ b/src/Pages/ProfilePage.css @@ -230,3 +230,24 @@ font-weight: normal; margin-left: 4px; } + +.icon-title { + font-weight: 600; + font-size: 19px; + line-height: 23px; + display: flex; + align-items: center; + margin-bottom: 22px; +} + +.icon-title svg { + margin-right: 8px; +} + +.icon-title h3 { + margin: 0; +} + +.icon-title select { + margin-left: auto; +} diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 523af5e9..456d8947 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -6,6 +6,8 @@ import { useNavigate, useParams } from "react-router-dom"; import { unwrap } from "Util"; import { formatShort } from "Number"; +import Note from "Element/Note"; +import Bookmarks from "Element/Bookmarks"; import RelaysMetadata from "Element/RelaysMetadata"; import { Tab, TabElement } from "Element/Tabs"; import Link from "Icons/Link"; @@ -13,6 +15,8 @@ import Qr from "Icons/Qr"; import Zap from "Icons/Zap"; import Envelope from "Icons/Envelope"; import useRelaysFeed from "Feed/RelaysFeed"; +import usePinnedFeed from "Feed/PinnedFeed"; +import useBookmarkFeed from "Feed/BookmarkFeed"; import { useUserProfile } from "Feed/ProfileFeed"; import useZapsFeed from "Feed/ZapsFeed"; import { default as ZapElement, parseZap } from "Element/Zap"; @@ -49,6 +53,7 @@ const ZAPS = 4; const MUTED = 5; const BLOCKED = 6; const RELAYS = 7; +const BOOKMARKS = 8; export default function ProfilePage() { const { formatMessage } = useIntl(); @@ -62,6 +67,8 @@ export default function ProfilePage() { const isMe = loginPubKey === id; const [showLnQr, setShowLnQr] = useState(false); const [showProfileQr, setShowProfileQr] = useState(false); + const { pinned, related: pinRelated } = usePinnedFeed(id); + const { bookmarks, related: bookmarkRelated } = useBookmarkFeed(id); const aboutText = user?.about || ""; const about = Text({ content: aboutText, @@ -90,11 +97,14 @@ export default function ProfilePage() { Muted: { text: formatMessage(messages.Muted), value: MUTED }, Blocked: { text: formatMessage(messages.Blocked), value: BLOCKED }, Relays: { text: formatMessage(messages.Relays), value: RELAYS }, + Bookmarks: { text: formatMessage(messages.Bookmarks), value: BOOKMARKS }, }; const [tab, setTab] = useState(ProfileTab.Notes); - const optionalTabs = [zapsTotal > 0 && ProfileTab.Zaps, relays.length > 0 && ProfileTab.Relays].filter(a => - unwrap(a) - ) as Tab[]; + const optionalTabs = [ + zapsTotal > 0 && ProfileTab.Zaps, + relays.length > 0 && ProfileTab.Relays, + bookmarks.length > 0 && ProfileTab.Bookmarks, + ].filter(a => unwrap(a)) as Tab[]; useEffect(() => { setTab(ProfileTab.Notes); @@ -162,17 +172,31 @@ export default function ProfilePage() { switch (tab.value) { case NOTES: return ( - + <> +
+ {pinned.map(n => { + return ( + + ); + })} +
+ + ); case ZAPS: { return ( @@ -215,6 +239,9 @@ export default function ProfilePage() { case RELAYS: { return ; } + case BOOKMARKS: { + return ; + } } } diff --git a/src/Pages/messages.ts b/src/Pages/messages.ts index e23f47f7..4139bce8 100644 --- a/src/Pages/messages.ts +++ b/src/Pages/messages.ts @@ -36,4 +36,6 @@ export default defineMessages({ Relays: { defaultMessage: "Relays", }, + Bookmarks: { defaultMessage: "Bookmarks" }, + BookmarksCount: { defaultMessage: "Bookmarks ({n})" }, }); diff --git a/src/State/Login.ts b/src/State/Login.ts index 8e892dd3..efb02039 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -115,6 +115,26 @@ export interface LoginStore { */ latestFollows: number; + /** + * A list of event ids this user has pinned + */ + pinned: HexKey[]; + + /** + * Last seen pinned list event timestamp + */ + latestPinned: number; + + /** + * A list of event ids this user has bookmarked + */ + bookmarked: HexKey[]; + + /** + * Last seen bookmark list event timestamp + */ + latestBookmarked: number; + /** * A list of pubkeys this user has muted */ @@ -172,6 +192,10 @@ export const InitState = { latestRelays: 0, follows: [], latestFollows: 0, + pinned: [], + latestPinned: 0, + bookmarked: [], + latestBookmarked: 0, muted: [], blocked: [], latestMuted: 0, @@ -328,6 +352,22 @@ const LoginSlice = createSlice({ state.latestMuted = createdAt; } }, + setPinned(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) { + const { createdAt, keys } = action.payload; + if (createdAt >= state.latestPinned) { + const pinned = new Set([...keys]); + state.pinned = Array.from(pinned); + state.latestPinned = createdAt; + } + }, + setBookmarked(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) { + const { createdAt, keys } = action.payload; + if (createdAt >= state.latestBookmarked) { + const bookmarked = new Set([...keys]); + state.bookmarked = Array.from(bookmarked); + state.latestBookmarked = createdAt; + } + }, setBlocked(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) { const { createdAt, keys } = action.payload; if (createdAt >= state.latestMuted) { @@ -388,6 +428,8 @@ export const { removeRelay, setFollows, setMuted, + setPinned, + setBookmarked, setBlocked, addDirectMessage, incDmInteraction, diff --git a/src/Util.ts b/src/Util.ts index b63c2168..2c7685d1 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -190,3 +190,11 @@ export function randomSample(coll: T[], size: number) { const random = [...coll]; return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size); } + +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]; + } +} diff --git a/src/lang.json b/src/lang.json index 648dedc0..507928b8 100644 --- a/src/lang.json +++ b/src/lang.json @@ -218,6 +218,9 @@ "GFOoEE": { "string": "Salt" }, + "GL8aXW": { + "string": "Bookmarks ({n})" + }, "GspYR7": { "string": "{n} Dislike" }, @@ -354,6 +357,9 @@ "RoOyAh": { "string": "Relays" }, + "Rs4kCE": { + "string": "Bookmark" + }, "Sjo1P4": { "string": "Custom" }, @@ -459,12 +465,18 @@ "eR3YIn": { "string": "Posts" }, + "fWZYP5": { + "string": "Pinned" + }, "filwqD": { "string": "Read" }, "flnGvv": { "string": "What's on your mind?" }, + "fsB/4p": { + "string": "Saved" + }, "g5pX+a": { "string": "About" }, @@ -570,6 +582,9 @@ "nDejmx": { "string": "Unblock" }, + "nGBrvw": { + "string": "Bookmarks" + }, "nN9XTz": { "string": "Share your thoughts with {link}" }, @@ -599,6 +614,9 @@ "oxCa4R": { "string": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app." }, + "puLNUJ": { + "string": "Pin" + }, "pzTOmv": { "string": "Followers" }, diff --git a/src/translations/en.json b/src/translations/en.json index 7718f8fc..dc971485 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -149,6 +149,7 @@ "eHAneD": "Reaction emoji", "eJj8HD": "Get Verified", "eR3YIn": "Posts", + "fWZYP5": "Pinned", "filwqD": "Read", "flnGvv": "What's on your mind?", "g5pX+a": "About", @@ -195,6 +196,7 @@ "odhABf": "Login", "osUr8O": "You can also use these extensions to login to most Nostr sites.", "oxCa4R": "Getting an identifier helps confirm the real you to people who know you. Many people can have a username @jack, but there is only one jack@cash.app.", + "puLNUJ": "Pin", "pzTOmv": "Followers", "qUJTsT": "Blocked", "qdGuQo": "Your Private Key Is (do not share this with anyone)", -- 2.45.2 From 03795877349b3b0b6ab6f446f33b1551741ebf51 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 11 Feb 2023 12:05:44 +0100 Subject: [PATCH 02/11] intl --- src/translations/en.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/translations/en.json b/src/translations/en.json index dc971485..52890865 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -71,6 +71,7 @@ "FmXUJg": "follows you", "G/yZLu": "Remove", "GFOoEE": "Salt", + "GL8aXW": "Bookmarks ({n})", "GspYR7": "{n} Dislike", "H+vHiz": "Hex Key..", "H0JBH6": "Log Out", @@ -115,6 +116,7 @@ "RahCRH": "Expired", "RhDAoS": "Are you sure you want to delete {id}", "RoOyAh": "Relays", + "Rs4kCE": "Bookmark", "Sjo1P4": "Custom", "TpgeGw": "Hex Salt..", "UQ3pOC": "On Nostr, many people have the same username. User names and identity are separate things. You can get a unique identifier in the next step.", @@ -152,6 +154,7 @@ "fWZYP5": "Pinned", "filwqD": "Read", "flnGvv": "What's on your mind?", + "fsB/4p": "Saved", "g5pX+a": "About", "gBdUXk": "Save your keys!", "gDZkld": "Snort is a Nostr UI, nostr is a decentralised protocol for saving and distributing \"notes\".", @@ -187,6 +190,7 @@ "mKhgP9": "{n,plural,=0{} =1{zapped} other{zapped}}", "n1xHAH": "Get an identifier (optional)", "nDejmx": "Unblock", + "nGBrvw": "Bookmarks", "nN9XTz": "Share your thoughts with {link}", "nn1qb3": "Your donations are greatly appreciated", "nwZXeh": "{n} blocked", -- 2.45.2 From 9305a76a0d22ac4f706a6075b9b72d58645a71cd Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 11 Feb 2023 12:23:29 +0100 Subject: [PATCH 03/11] refactor --- src/Feed/BookmarkFeed.tsx | 62 ++-------------------------- src/Feed/PinnedFeed.tsx | 62 ++-------------------------- src/Feed/useNotelistSubscription.ts | 63 +++++++++++++++++++++++++++++ src/Pages/ProfilePage.tsx | 4 +- 4 files changed, 73 insertions(+), 118 deletions(-) create mode 100644 src/Feed/useNotelistSubscription.ts diff --git a/src/Feed/BookmarkFeed.tsx b/src/Feed/BookmarkFeed.tsx index d63bdc52..2335b052 100644 --- a/src/Feed/BookmarkFeed.tsx +++ b/src/Feed/BookmarkFeed.tsx @@ -1,64 +1,10 @@ -import { useMemo } from "react"; import { useSelector } from "react-redux"; -import { getNewest } from "Util"; -import { HexKey, Lists } from "Nostr"; -import EventKind from "Nostr/EventKind"; -import { Subscriptions } from "Nostr/Subscriptions"; -import useSubscription from "Feed/Subscription"; import { RootState } from "State/Store"; +import { HexKey, Lists } from "Nostr"; +import useNotelistSubscription from "Feed/useNotelistSubscription"; export default function useBookmarkFeed(pubkey: HexKey) { - const pref = useSelector((s: RootState) => s.login.preferences); - const { publicKey, bookmarked } = useSelector((s: RootState) => s.login); - const isMe = publicKey === pubkey; - - const sub = useMemo(() => { - if (isMe) return null; - const sub = new Subscriptions(); - sub.Id = `bookmark:${pubkey.slice(0, 12)}`; - sub.Kinds = new Set([EventKind.NoteLists]); - sub.Authors = new Set([pubkey]); - sub.DTags = new Set([Lists.Bookmarked]); - sub.Limit = 1; - return sub; - }, [pubkey]); - - const { store } = useSubscription(sub, { leaveOpen: true, cache: true }); - const etags = useMemo(() => { - if (isMe) return bookmarked; - const newest = getNewest(store.notes); - if (newest) { - const { tags } = newest; - return tags.filter(t => t[0] === "e").map(t => t[1]); - } - return []; - }, [store.notes, isMe]); - - const esub = useMemo(() => { - const s = new Subscriptions(); - s.Id = `bookmark-notes:${pubkey.slice(0, 12)}`; - s.Kinds = new Set([EventKind.TextNote]); - s.Ids = new Set(etags); - return s; - }, [etags]); - - const subRelated = useMemo(() => { - let sub: Subscriptions | undefined; - if (etags.length > 0 && pref.enableReactions) { - sub = new Subscriptions(); - sub.Id = `bookmark-related`; - sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]); - sub.ETags = new Set(etags); - } - return sub ?? null; - }, [etags, pref]); - - const bookmarkSub = useSubscription(esub, { leaveOpen: true, cache: true }); - const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true }); - - const bookmarks = bookmarkSub.store.notes; - const related = relatedSub.store.notes; - - return { bookmarks, related }; + const { bookmarked } = useSelector((s: RootState) => s.login); + return useNotelistSubscription(pubkey, Lists.Bookmarked, bookmarked); } diff --git a/src/Feed/PinnedFeed.tsx b/src/Feed/PinnedFeed.tsx index 99b6317d..d9e43a48 100644 --- a/src/Feed/PinnedFeed.tsx +++ b/src/Feed/PinnedFeed.tsx @@ -1,64 +1,10 @@ -import { useMemo } from "react"; import { useSelector } from "react-redux"; -import { getNewest } from "Util"; -import { HexKey, Lists } from "Nostr"; -import EventKind from "Nostr/EventKind"; -import { Subscriptions } from "Nostr/Subscriptions"; -import useSubscription from "Feed/Subscription"; import { RootState } from "State/Store"; +import { HexKey, Lists } from "Nostr"; +import useNotelistSubscription from "Feed/useNotelistSubscription"; export default function usePinnedFeed(pubkey: HexKey) { - const pref = useSelector((s: RootState) => s.login.preferences); - const { publicKey, pinned: pinnedNotes } = useSelector((s: RootState) => s.login); - const isMe = publicKey === pubkey; - - const sub = useMemo(() => { - if (isMe) return null; - const sub = new Subscriptions(); - sub.Id = `pinned:${pubkey.slice(0, 12)}`; - sub.Kinds = new Set([EventKind.NoteLists]); - sub.Authors = new Set([pubkey]); - sub.DTags = new Set([Lists.Pinned]); - sub.Limit = 1; - return sub; - }, [pubkey]); - - const { store } = useSubscription(sub, { leaveOpen: true, cache: true }); - const etags = useMemo(() => { - if (isMe) return pinnedNotes; - const newest = getNewest(store.notes); - if (newest) { - const { tags } = newest; - return tags.filter(t => t[0] === "e").map(t => t[1]); - } - return []; - }, [store.notes, isMe]); - - const subRelated = useMemo(() => { - let sub: Subscriptions | undefined; - if (etags.length > 0 && pref.enableReactions) { - sub = new Subscriptions(); - sub.Id = `pinned-related`; - sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]); - sub.ETags = new Set(etags); - } - return sub ?? null; - }, [etags, pref]); - - const esub = useMemo(() => { - const s = new Subscriptions(); - s.Id = `pinned-notes:${pubkey.slice(0, 12)}`; - s.Kinds = new Set([EventKind.TextNote]); - s.Ids = new Set(etags); - return s; - }, [etags]); - - const pinnedSub = useSubscription(esub, { leaveOpen: true, cache: true }); - const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true }); - - const pinned = pinnedSub.store.notes; - const related = relatedSub.store.notes; - - return { pinned, related }; + const { pinned } = useSelector((s: RootState) => s.login); + return useNotelistSubscription(pubkey, Lists.Pinned, pinned); } diff --git a/src/Feed/useNotelistSubscription.ts b/src/Feed/useNotelistSubscription.ts new file mode 100644 index 00000000..b89969ac --- /dev/null +++ b/src/Feed/useNotelistSubscription.ts @@ -0,0 +1,63 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { getNewest } from "Util"; +import { HexKey, Lists } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import { Subscriptions } from "Nostr/Subscriptions"; +import useSubscription from "Feed/Subscription"; +import { RootState } from "State/Store"; + +export default function useNotelistSubscription(pubkey: HexKey, l: Lists, defaultIds: HexKey[]) { + const { preferences, publicKey } = useSelector((s: RootState) => s.login); + const isMe = publicKey === pubkey; + + const sub = useMemo(() => { + if (isMe) return null; + const sub = new Subscriptions(); + sub.Id = `note-list-${l}:${pubkey.slice(0, 12)}`; + sub.Kinds = new Set([EventKind.NoteLists]); + sub.Authors = new Set([pubkey]); + sub.DTags = new Set([l]); + sub.Limit = 1; + return sub; + }, [pubkey]); + + const { store } = useSubscription(sub, { leaveOpen: true, cache: true }); + const etags = useMemo(() => { + if (isMe) return defaultIds; + const newest = getNewest(store.notes); + if (newest) { + const { tags } = newest; + return tags.filter(t => t[0] === "e").map(t => t[1]); + } + return []; + }, [store.notes, isMe, defaultIds]); + + const esub = useMemo(() => { + const s = new Subscriptions(); + s.Id = `${l}-notes:${pubkey.slice(0, 12)}`; + s.Kinds = new Set([EventKind.TextNote]); + s.Ids = new Set(etags); + return s; + }, [etags]); + + const subRelated = useMemo(() => { + let sub: Subscriptions | undefined; + if (etags.length > 0 && preferences.enableReactions) { + sub = new Subscriptions(); + sub.Id = `${l}-related`; + sub.Kinds = new Set([EventKind.Reaction, EventKind.Repost, EventKind.Deletion, EventKind.ZapReceipt]); + sub.ETags = new Set(etags); + } + return sub ?? null; + }, [etags, preferences]); + + const mainSub = useSubscription(esub, { leaveOpen: true, cache: true }); + const relatedSub = useSubscription(subRelated, { leaveOpen: true, cache: true }); + + const notes = mainSub.store.notes.filter(e => etags.includes(e.id)); + const related = relatedSub.store.notes; + + return { notes, related }; +} diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 456d8947..4c80a8e2 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -67,8 +67,8 @@ export default function ProfilePage() { const isMe = loginPubKey === id; const [showLnQr, setShowLnQr] = useState(false); const [showProfileQr, setShowProfileQr] = useState(false); - const { pinned, related: pinRelated } = usePinnedFeed(id); - const { bookmarks, related: bookmarkRelated } = useBookmarkFeed(id); + const { notes: pinned, related: pinRelated } = usePinnedFeed(id); + const { notes: bookmarks, related: bookmarkRelated } = useBookmarkFeed(id); const aboutText = user?.about || ""; const about = Text({ content: aboutText, -- 2.45.2 From 1ba3ed5de943d7069dadfb182129eef249dcbb91 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 11 Feb 2023 12:29:53 +0100 Subject: [PATCH 04/11] hide time in bookmarks --- src/Element/Note.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 33af85ee..4333952b 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -229,7 +229,7 @@ export default function Note(props: NoteProps) {
)} - + {!options.showBookmarked && } )} {options.showPinned && ( -- 2.45.2 From 5cc231c1d18fc1a405938f6c9fab5b828d5102c4 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 11 Feb 2023 12:30:54 +0100 Subject: [PATCH 05/11] format --- src/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/translations/en.json b/src/translations/en.json index 52890865..b6e877be 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -243,4 +243,4 @@ "zjJZBd": "You're ready!", "zonsdq": "Failed to load LNURL service", "zvCDao": "Automatically show latest notes" -} \ No newline at end of file +} -- 2.45.2 From a912d70aaadd245c83e865354ce008fb90922a1c Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 11 Feb 2023 22:29:00 +0100 Subject: [PATCH 06/11] refactor use util fn --- src/Element/Bookmarks.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/Element/Bookmarks.tsx b/src/Element/Bookmarks.tsx index 50cf4f3c..e18f04a9 100644 --- a/src/Element/Bookmarks.tsx +++ b/src/Element/Bookmarks.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, ChangeEvent } from "react"; import { useSelector } from "react-redux"; import { FormattedMessage } from "react-intl"; +import { dedupeByPubkey } from "Util"; import Note from "Element/Note"; import Bookmark from "Icons/Bookmark"; import { HexKey, TaggedRawEvent } from "Nostr"; @@ -20,19 +21,7 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => { const [onlyPubkey, setOnlyPubkey] = useState("all"); const loginPubKey = useSelector((s: RootState) => s.login.publicKey); const ps = useMemo(() => { - return bookmarks.reduce( - ({ ps, seen }, e) => { - if (seen.has(e.pubkey)) { - return { ps, seen }; - } - seen.add(e.pubkey); - return { - seen, - ps: [...ps, e.pubkey], - }; - }, - { ps: [] as HexKey[], seen: new Set() } - ).ps; + return dedupeByPubkey(bookmarks).map(ev => ev.pubkey); }, [bookmarks]); const profiles = useUserProfiles(ps); -- 2.45.2 From e05bec8475e93c4056edb382052ae1bf11235196 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sun, 12 Feb 2023 22:15:50 +0100 Subject: [PATCH 07/11] feat: follow tags --- src/Element/Tabs.tsx | 3 +- src/Feed/EventPublisher.ts | 12 ++++++++ src/Feed/LoginFeed.ts | 28 ++++++++++++++++++ src/Pages/HashTagsPage.tsx | 37 +++++++++++++++++++++++- src/Pages/Root.tsx | 58 +++++++++++++++++++++----------------- src/State/Login.ts | 21 ++++++++++++++ src/index.css | 11 ++++++++ 7 files changed, 141 insertions(+), 29 deletions(-) diff --git a/src/Element/Tabs.tsx b/src/Element/Tabs.tsx index 3da948e4..39696b8b 100644 --- a/src/Element/Tabs.tsx +++ b/src/Element/Tabs.tsx @@ -1,8 +1,7 @@ import "./Tabs.css"; -import { ReactElement } from "react"; export interface Tab { - text: ReactElement | string; + text: string; value: number; disabled?: boolean; } diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 795562cf..5c0d24b9 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -165,6 +165,18 @@ export default function useEventPublisher() { return await signEvent(ev); } }, + tags: async (tags: string[]) => { + if (pubKey) { + const ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.TagLists; + ev.Tags.push(new Tag(["d", Lists.Followed], ev.Tags.length)); + tags.forEach(t => { + ev.Tags.push(new Tag(["t", t], ev.Tags.length)); + }); + ev.Content = ""; + return await signEvent(ev); + } + }, metadata: async (obj: UserMetadata) => { if (pubKey) { const ev = NEvent.ForPubKey(pubKey); diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 2e9b1331..59bfe021 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -12,6 +12,7 @@ import { setFollows, setRelays, setMuted, + setTags, setPinned, setBookmarked, setBlocked, @@ -79,6 +80,19 @@ export default function useLoginFeed() { return sub; }, [pubKey]); + const subTags = useMemo(() => { + if (!pubKey) return null; + + const sub = new Subscriptions(); + sub.Id = "login:tags"; + sub.Kinds = new Set([EventKind.TagLists]); + sub.Authors = new Set([pubKey]); + sub.DTags = new Set([Lists.Followed]); + sub.Limit = 1; + + return sub; + }, [pubKey]); + const subPinned = useMemo(() => { if (!pubKey) return null; @@ -132,6 +146,7 @@ export default function useLoginFeed() { const dmsFeed = useSubscription(subDms, { leaveOpen: true, cache: true }); const mutedFeed = useSubscription(subMuted, { leaveOpen: true, cache: true }); const pinnedFeed = useSubscription(subPinned, { leaveOpen: true, cache: true }); + const tagsFeed = useSubscription(subTags, { leaveOpen: true, cache: true }); const bookmarkFeed = useSubscription(subBookmarks, { leaveOpen: true, cache: true }); useEffect(() => { @@ -223,6 +238,19 @@ export default function useLoginFeed() { } }, [dispatch, pinnedFeed.store]); + useEffect(() => { + const newest = getNewest(tagsFeed.store.notes); + if (newest) { + const tags = newest.tags.filter(p => p && p.length === 2 && p[0] === "t").map(p => p[1]); + dispatch( + setTags({ + tags, + createdAt: newest.created_at, + }) + ); + } + }, [dispatch, tagsFeed.store]); + useEffect(() => { const newest = getNewest(bookmarkFeed.store.notes); if (newest) { diff --git a/src/Pages/HashTagsPage.tsx b/src/Pages/HashTagsPage.tsx index 9d04633a..01b5a358 100644 --- a/src/Pages/HashTagsPage.tsx +++ b/src/Pages/HashTagsPage.tsx @@ -1,13 +1,48 @@ +import { useMemo } from "react"; import { useParams } from "react-router-dom"; +import { FormattedMessage } from "react-intl"; +import { useSelector, useDispatch } from "react-redux"; import Timeline from "Element/Timeline"; +import useEventPublisher from "Feed/EventPublisher"; +import { setTags } from "State/Login"; +import type { RootState } from "State/Store"; const HashTagsPage = () => { const params = useParams(); const tag = (params.tag ?? "").toLowerCase(); + const dispatch = useDispatch(); + const { tags } = useSelector((s: RootState) => s.login); + const isFollowing = useMemo(() => { + return tags.includes(tag); + }, [tags, tag]); + const publisher = useEventPublisher(); + + function followTags(ts: string[]) { + dispatch( + setTags({ + tags: ts, + createdAt: new Date().getTime(), + }) + ); + publisher.tags(ts).then(ev => publisher.broadcast(ev)); + } return ( <> -

#{tag}

+
+
+

#{tag}

+ {isFollowing ? ( + + ) : ( + + )} +
+
= { - Posts: { - text: , - value: 0, - }, - PostsAndReplies: { - text: , - value: 1, - }, - Global: { - text: , - value: 2, - }, -}; - export default function RootPage() { - const [loggedOut, pubKey, follows] = useSelector( - s => [s.login.loggedOut, s.login.publicKey, s.login.follows] - ); + const { formatMessage } = useIntl(); + const { loggedOut, publicKey: pubKey, follows, tags } = useSelector((s: RootState) => s.login); + const RootTab: Record = { + Posts: { + text: formatMessage(messages.Posts), + value: 0, + }, + PostsAndReplies: { + text: formatMessage(messages.Conversations), + value: 1, + }, + Global: { + text: formatMessage(messages.Global), + value: 2, + }, + }; const [tab, setTab] = useState(RootTab.Posts); + const tagTabs = tags.map((t, idx) => { + return { text: `#${t}`, value: idx + 3 }; + }); + const tabs = [RootTab.Posts, RootTab.PostsAndReplies, RootTab.Global, ...tagTabs]; function followHints() { if (follows?.length === 0 && pubKey && tab !== RootTab.Global) { @@ -51,14 +52,19 @@ export default function RootPage() { } const isGlobal = loggedOut || tab.value === RootTab.Global.value; - const timelineSubect: TimelineSubject = isGlobal - ? { type: "global", items: [], discriminator: "all" } - : { type: "pubkey", items: follows, discriminator: "follows" }; + const timelineSubect: TimelineSubject = (() => { + if (isGlobal) { + return { type: "global", items: [], discriminator: "all" }; + } + if (tab.value >= 3) { + return { type: "hashtag", items: [tab.text.slice(1)], discriminator: "all" }; + } + + return { type: "pubkey", items: follows, discriminator: "follows" }; + })(); return ( <> -
- {pubKey && } -
+
{pubKey && }
{followHints()} ) { + const { createdAt, tags } = action.payload; + if (createdAt >= state.latestTags) { + const newTags = new Set([...tags]); + state.tags = Array.from(newTags); + state.latestTags = createdAt; + } + }, setMuted(state, action: PayloadAction<{ createdAt: number; keys: HexKey[] }>) { const { createdAt, keys } = action.payload; if (createdAt >= state.latestMuted) { @@ -427,6 +447,7 @@ export const { setRelays, removeRelay, setFollows, + setTags, setMuted, setPinned, setBookmarked, diff --git a/src/index.css b/src/index.css index d7a9ccf5..06ec9243 100644 --- a/src/index.css +++ b/src/index.css @@ -570,3 +570,14 @@ button.tall { transform: rotate(180deg); transition: transform 300ms ease-in-out; } + +.action-heading { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.action-heading button { + width: 98px; +} -- 2.45.2 From ed710a82809822933a836b8bf750d43db6c0be4d Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sun, 12 Feb 2023 22:20:15 +0100 Subject: [PATCH 08/11] add horizontal scroll to all tabs --- src/Element/Tabs.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Element/Tabs.tsx b/src/Element/Tabs.tsx index 39696b8b..661ea67d 100644 --- a/src/Element/Tabs.tsx +++ b/src/Element/Tabs.tsx @@ -1,4 +1,5 @@ import "./Tabs.css"; +import useHorizontalScroll from "Hooks/useHorizontalScroll"; export interface Tab { text: string; @@ -27,8 +28,9 @@ export const TabElement = ({ t, tab, setTab }: TabElementProps) => { }; const Tabs = ({ tabs, tab, setTab }: TabsProps) => { + const horizontalScroll = useHorizontalScroll(); return ( -
+
{tabs.map(t => ( ))} -- 2.45.2 From aeb3cb12dfebdc7e34078976ab0502f1e91cddee Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Mon, 13 Feb 2023 01:03:55 +0100 Subject: [PATCH 09/11] address review comments --- src/Element/Bookmarks.tsx | 4 ---- src/Nostr/EventKind.ts | 10 +++++----- src/Pages/Root.tsx | 3 ++- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Element/Bookmarks.tsx b/src/Element/Bookmarks.tsx index e18f04a9..6dc51df1 100644 --- a/src/Element/Bookmarks.tsx +++ b/src/Element/Bookmarks.tsx @@ -33,10 +33,6 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => { return (
- {" "} -

- -