From 5b9df00aa9f6e1a8e552a9b766047ae1f011d0f4 Mon Sep 17 00:00:00 2001 From: Bojan Mojsilovic Date: Fri, 29 Mar 2024 15:07:54 +0100 Subject: [PATCH] Add bookmarks --- src/Router.tsx | 2 + src/assets/icons/bookmark_empty.svg | 3 + src/assets/icons/bookmark_filled.svg | 3 + .../BookmarkNote/BookmarkNote.module.scss | 27 ++ src/components/BookmarkNote/BookmarkNote.tsx | 162 ++++++++++ src/components/Layout/Layout.tsx | 4 +- src/components/NavLink/NavLink.module.scss | 5 + src/components/NavMenu/NavMenu.tsx | 5 + src/components/Note/Note.module.scss | 7 + src/components/Note/Note.tsx | 13 +- src/components/Note/NoteFooter/NoteFooter.tsx | 94 +++--- src/constants.ts | 1 + src/contexts/AccountContext.tsx | 48 ++- src/lib/feed.ts | 10 +- src/lib/localStore.ts | 20 ++ src/lib/profile.ts | 21 ++ src/pages/Bookmarks.module.scss | 26 ++ src/pages/Bookmarks.tsx | 278 ++++++++++++++++++ src/palette.scss | 4 + src/translations.ts | 62 +++- src/types/primal.d.ts | 16 +- 21 files changed, 748 insertions(+), 63 deletions(-) create mode 100644 src/assets/icons/bookmark_empty.svg create mode 100644 src/assets/icons/bookmark_filled.svg create mode 100644 src/components/BookmarkNote/BookmarkNote.module.scss create mode 100644 src/components/BookmarkNote/BookmarkNote.tsx create mode 100644 src/pages/Bookmarks.module.scss create mode 100644 src/pages/Bookmarks.tsx diff --git a/src/Router.tsx b/src/Router.tsx index a7bf9ae..0acd9b8 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -20,6 +20,7 @@ const Layout = lazy(() => import('./components/Layout/Layout')); const Explore = lazy(() => import('./pages/Explore')); const Thread = lazy(() => import('./pages/Thread')); const Messages = lazy(() => import('./pages/Messages')); +const Bookmarks = lazy(() => import('./pages/Bookmarks')); const Notifications = lazy(() => import('./pages/Notifications')); const Downloads = lazy(() => import('./pages/Downloads')); const Settings = lazy(() => import('./pages/Settings/Settings')); @@ -126,6 +127,7 @@ const Router: Component = () => { + diff --git a/src/assets/icons/bookmark_empty.svg b/src/assets/icons/bookmark_empty.svg new file mode 100644 index 0000000..cdbcca5 --- /dev/null +++ b/src/assets/icons/bookmark_empty.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/bookmark_filled.svg b/src/assets/icons/bookmark_filled.svg new file mode 100644 index 0000000..df8ad00 --- /dev/null +++ b/src/assets/icons/bookmark_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/BookmarkNote/BookmarkNote.module.scss b/src/components/BookmarkNote/BookmarkNote.module.scss new file mode 100644 index 0000000..92a7b1d --- /dev/null +++ b/src/components/BookmarkNote/BookmarkNote.module.scss @@ -0,0 +1,27 @@ +.bookmark { + .emptyBookmark { + width: 16px; + height: 16px; + display: inline-block; + margin: 0px 0px; + background-color: var(--text-tertiary); + -webkit-mask: url(../../assets/icons/bookmark_empty.svg) no-repeat 0 / 16px; + mask: url(../../assets/icons/bookmark_empty.svg) no-repeat 0 / 16px; + } + + .fullBookmark { + width: 16px; + height: 16px; + display: inline-block; + margin: 0px 0px; + background-color: var(--active-bookmarked); + -webkit-mask: url(../../assets/icons/bookmark_filled.svg) no-repeat 0 / 16px; + mask: url(../../assets/icons/bookmark_filled.svg) no-repeat 0 / 16px; + } + + &:hover { + .emptyBookmark { + background-color: var(--active-bookmarked); + } + } +} diff --git a/src/components/BookmarkNote/BookmarkNote.tsx b/src/components/BookmarkNote/BookmarkNote.tsx new file mode 100644 index 0000000..d56066e --- /dev/null +++ b/src/components/BookmarkNote/BookmarkNote.tsx @@ -0,0 +1,162 @@ +import { useIntl } from '@cookbook/solid-intl'; +import { Component, createEffect, createSignal, Match, Show, Switch } from 'solid-js'; +import { APP_ID } from '../../App'; +import { Kind } from '../../constants'; +import { useAccountContext } from '../../contexts/AccountContext'; +import { useAppContext } from '../../contexts/AppContext'; +import { getUserFeed } from '../../lib/feed'; +import { logWarning } from '../../lib/logger'; +import { getBookmarks, sendBookmarks } from '../../lib/profile'; +import { subscribeTo } from '../../sockets'; +import { PrimalNote } from '../../types/primal'; +import ButtonGhost from '../Buttons/ButtonGhost'; +import { account, bookmarks as tBookmarks } from '../../translations'; + +import styles from './BookmarkNote.module.scss'; +import { saveBookmarks } from '../../lib/localStore'; +import { importEvents, triggerImportEvents } from '../../lib/notes'; + +const BookmarkNote: Component<{ note: PrimalNote }> = (props) => { + const account = useAccountContext(); + const app = useAppContext(); + const intl = useIntl(); + + const [isBookmarked, setIsBookmarked] = createSignal(false); + const [bookmarkInProgress, setBookmarkInProgress] = createSignal(false); + + createEffect(() => { + setIsBookmarked(() => account?.bookmarks.includes(props.note.post.id) || false); + }) + + const updateBookmarks = async (bookmarks: string[]) => { + if (!account) return; + const tags = bookmarks.map(b => ['e', b]); + const date = Math.floor((new Date()).getTime() / 1000); + + account.actions.updateBookmarks(bookmarks) + saveBookmarks(account.publicKey, bookmarks); + const { success, note} = await sendBookmarks([...tags], date, '', account?.relays, account?.relaySettings); + + if (success && note) { + triggerImportEvents([note], `bookmark_import_${APP_ID}`) + } + + }; + + const addBookmark = async (bookmarks: string[]) => { + if (account && !bookmarks.includes(props.note.post.id)) { + const bookmarksToAdd = [...bookmarks, props.note.post.id]; + + if (bookmarksToAdd.length < 2) { + logWarning('BOOKMARK ISSUE: ', `before_bookmark_${APP_ID}`); + + app?.actions.openConfirmModal({ + title: intl.formatMessage(tBookmarks.confirm.title), + description: intl.formatMessage(tBookmarks.confirm.description), + confirmLabel: intl.formatMessage(tBookmarks.confirm.confirm), + abortLabel: intl.formatMessage(tBookmarks.confirm.abort), + onConfirm: async () => { + await updateBookmarks(bookmarksToAdd); + app.actions.closeConfirmModal(); + }, + onAbort: app.actions.closeConfirmModal, + }) + + return; + } + + await updateBookmarks(bookmarksToAdd); + } + } + + const removeBookmark = async (bookmarks: string[]) => { + if (account && bookmarks.includes(props.note.post.id)) { + const bookmarksToAdd = bookmarks.filter(b => b !== props.note.post.id); + + if (bookmarksToAdd.length < 1) { + logWarning('BOOKMARK ISSUE: ', `before_bookmark_${APP_ID}`); + + app?.actions.openConfirmModal({ + title: intl.formatMessage(tBookmarks.confirm.titleZero), + description: intl.formatMessage(tBookmarks.confirm.descriptionZero), + confirmLabel: intl.formatMessage(tBookmarks.confirm.confirmZero), + abortLabel: intl.formatMessage(tBookmarks.confirm.abortZero), + onConfirm: async () => { + await updateBookmarks(bookmarksToAdd); + app.actions.closeConfirmModal(); + }, + onAbort: app.actions.closeConfirmModal, + }) + + return; + } + + await updateBookmarks(bookmarksToAdd); + } + } + + const doBookmark = (remove: boolean, then?: () => void) => { + + if (!account?.publicKey) { + return; + } + + let bookmarks: string[] = [] + + const unsub = subscribeTo(`before_bookmark_${APP_ID}`, async (type, subId, content) => { + if (type === 'EOSE') { + + if (remove) { + await removeBookmark(bookmarks); + } + else { + await addBookmark(bookmarks); + } + + then && then(); + setBookmarkInProgress(() => false); + unsub(); + return; + } + + if (type === 'EVENT') { + if (!content || content.kind !== Kind.Bookmarks) return; + + bookmarks = content.tags.reduce((acc, t) => { + if (t[0] === 'e') { + return [...acc, t[1]]; + } + return [...acc]; + }, []); + } + }); + + setBookmarkInProgress(() => true); + getBookmarks(account.publicKey, `before_bookmark_${APP_ID}`); + } + + return ( +
+ { + e.preventDefault(); + + doBookmark(isBookmarked()); + + }} + disabled={bookmarkInProgress()} + > +
+ } + > +
+ + + + ) +} + +export default BookmarkNote; diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index a6ffb67..ce619be 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -7,7 +7,6 @@ import NavMenu from '../NavMenu/NavMenu'; import ProfileWidget from '../ProfileWidget/ProfileWidget'; import NewNote from '../NewNote/NewNote'; import { useAccountContext } from '../../contexts/AccountContext'; -import zapSM from '../../assets/lottie/zap_sm.json'; import zapMD from '../../assets/lottie/zap_md.json'; import { useHomeContext } from '../../contexts/HomeContext'; import { SendNoteResult } from '../../types/primal'; @@ -15,7 +14,6 @@ import { useProfileContext } from '../../contexts/ProfileContext'; import Branding from '../Branding/Branding'; import BannerIOS, { isIOS } from '../BannerIOS/BannerIOS'; import ZapAnimation from '../ZapAnimation/ZapAnimation'; -import Landing from '../../pages/Landing'; import ReactionsModal from '../ReactionsModal/ReactionsModal'; import { useAppContext } from '../../contexts/AppContext'; import CustomZap from '../CustomZap/CustomZap'; @@ -92,7 +90,7 @@ const Layout: Component = () => { } createEffect(() => { - if (location.pathname === '/' || account?.isKeyLookupDone) return; + if (location.pathname === '/') return; account?.actions.checkNostrKey(); }); diff --git a/src/components/NavLink/NavLink.module.scss b/src/components/NavLink/NavLink.module.scss index ee76595..f71f86b 100644 --- a/src/components/NavLink/NavLink.module.scss +++ b/src/components/NavLink/NavLink.module.scss @@ -73,6 +73,11 @@ -webkit-mask: url(../../assets/icons/help.svg) no-repeat center; mask: url(../../assets/icons/help.svg) no-repeat center; } +.bookmarkIcon { + @include iconNav; + -webkit-mask: url(../../assets/icons/bookmark_empty.svg) no-repeat 0 / 100%; + mask: url(../../assets/icons/bookmark_empty.svg) no-repeat 0 / 100%; +} .active { display: flex; diff --git a/src/components/NavMenu/NavMenu.tsx b/src/components/NavMenu/NavMenu.tsx index 3aacf4c..6c58529 100644 --- a/src/components/NavMenu/NavMenu.tsx +++ b/src/components/NavMenu/NavMenu.tsx @@ -38,6 +38,11 @@ const NavMenu: Component< { id?: string } > = (props) => { icon: 'messagesIcon', bubble: () => messages?.messageCount || 0, }, + { + to: '/bookmarks', + label: intl.formatMessage(t.bookmarks), + icon: 'bookmarkIcon', + }, { to: '/notifications', label: intl.formatMessage(t.notifications), diff --git a/src/components/Note/Note.module.scss b/src/components/Note/Note.module.scss index 6ad0870..5092678 100644 --- a/src/components/Note/Note.module.scss +++ b/src/components/Note/Note.module.scss @@ -1,4 +1,5 @@ .note { + position: relative; display: flex; flex-direction: column; padding: 12px; @@ -258,3 +259,9 @@ } } + +.upRightFloater { + position: absolute; + top: 4px; + right: 4px; +} diff --git a/src/components/Note/Note.tsx b/src/components/Note/Note.tsx index 95b0578..6046a99 100644 --- a/src/components/Note/Note.tsx +++ b/src/components/Note/Note.tsx @@ -1,23 +1,18 @@ import { A } from '@solidjs/router'; -import { Component, createSignal, Show } from 'solid-js'; +import { Component, Show } from 'solid-js'; import { PrimalNote } from '../../types/primal'; import ParsedNote from '../ParsedNote/ParsedNote'; import NoteFooter from './NoteFooter/NoteFooter'; -import NoteHeader from './NoteHeader/NoteHeader'; import styles from './Note.module.scss'; import { useThreadContext } from '../../contexts/ThreadContext'; import { useIntl } from '@cookbook/solid-intl'; -import { authorName, userName } from '../../stores/profile'; -import { note as t } from '../../translations'; import { hookForDev } from '../../lib/devTools'; -import NoteReplyHeader from './NoteHeader/NoteReplyHeader'; import Avatar from '../Avatar/Avatar'; import NoteAuthorInfo from './NoteAuthorInfo'; import NoteRepostHeader from './NoteRepostHeader'; -import PrimalMenu from '../PrimalMenu/PrimalMenu'; -import NoteContextMenu from './NoteContextMenu'; import NoteReplyToHeader from './NoteReplyToHeader'; +import BookmarkNote from '../BookmarkNote/BookmarkNote'; const Note: Component<{ note: PrimalNote, id?: string, parent?: boolean, shorten?: boolean }> = (props) => { @@ -63,6 +58,10 @@ const Note: Component<{ note: PrimalNote, id?: string, parent?: boolean, shorten time={props.note.post.created_at} /> +
+ +
+
diff --git a/src/components/Note/NoteFooter/NoteFooter.tsx b/src/components/Note/NoteFooter/NoteFooter.tsx index 19890f5..5ad47bb 100644 --- a/src/components/Note/NoteFooter/NoteFooter.tsx +++ b/src/components/Note/NoteFooter/NoteFooter.tsx @@ -9,6 +9,7 @@ import { useIntl } from '@cookbook/solid-intl'; import { truncateNumber } from '../../../lib/notifications'; import { canUserReceiveZaps, zapNote } from '../../../lib/zap'; +import CustomZap from '../../CustomZap/CustomZap'; import { useSettingsContext } from '../../../contexts/SettingsContext'; import zapMD from '../../../assets/lottie/zap_md.json'; @@ -42,8 +43,16 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = const [isRepostMenuVisible, setIsRepostMenuVisible] = createSignal(false); + const [showZapAnim, setShowZapAnim] = createSignal(false); + const [hideZapIcon, setHideZapIcon] = createSignal(false); + const [zappedNow, setZappedNow] = createSignal(false); + const [zappedAmount, setZappedAmount] = createSignal(0); + const [isZapping, setIsZapping] = createSignal(false); + + let quickZapDelay = 0; let footerDiv: HTMLDivElement | undefined; let noteContextMenu: HTMLDivElement | undefined; + let repostMenu: HTMLDivElement | undefined; const repostMenuItems: MenuItem[] = [ { @@ -58,6 +67,7 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = }, ]; + const onClickOutside = (e: MouseEvent) => { if ( !document?.getElementById(`repost_menu_${props.note.post.id}`)?.contains(e.target as Node) @@ -155,44 +165,49 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = } }; - let quickZapDelay = 0; - const [isZapping, setIsZapping] = createSignal(false); + const onConfirmZap = (zapOption: ZapOption) => { + app?.actions.closeCustomZapModal(); + setZappedAmount(() => zapOption.amount || 0); + setZappedNow(true); + setZapped(true); + animateZap(); + }; + + const onSuccessZap = (zapOption: ZapOption) => { + app?.actions.closeCustomZapModal(); + setIsZapping(false); + setZappedNow(false); + setShowZapAnim(false); + setHideZapIcon(false); + setZapped(true); + }; + + const onFailZap = (zapOption: ZapOption) => { + setZappedAmount(() => -(zapOption.amount || 0)); + setZappedNow(true); + app?.actions.closeCustomZapModal(); + setIsZapping(false); + setShowZapAnim(false); + setHideZapIcon(false); + setZapped(props.note.post.noteActions.zapped); + }; + + const onCancelZap = (zapOption: ZapOption) => { + setZappedAmount(() => -(zapOption.amount || 0)); + setZappedNow(true); + app?.actions.closeCustomZapModal(); + setIsZapping(false); + setShowZapAnim(false); + setHideZapIcon(false); + setZapped(props.note.post.noteActions.zapped); + }; const customZapInfo: CustomZapInfo = { note: props.note, - onConfirm: (zapOption: ZapOption) => { - app?.actions.closeCustomZapModal(); - setZappedAmount(() => zapOption.amount || 0); - setZappedNow(true); - setZapped(true); - animateZap(); - }, - onSuccess: (zapOption: ZapOption) => { - app?.actions.closeCustomZapModal(); - setIsZapping(false); - setZappedNow(false); - setShowZapAnim(false); - setHideZapIcon(false); - setZapped(true); - }, - onFail: (zapOption: ZapOption) => { - setZappedAmount(() => -(zapOption.amount || 0)); - setZappedNow(true); - app?.actions.closeCustomZapModal(); - setIsZapping(false); - setShowZapAnim(false); - setHideZapIcon(false); - setZapped(props.note.post.noteActions.zapped); - }, - onCancel: (zapOption: ZapOption) => { - setZappedAmount(() => -(zapOption.amount || 0)); - setZappedNow(true); - app?.actions.closeCustomZapModal(); - setIsZapping(false); - setShowZapAnim(false); - setHideZapIcon(false); - setZapped(props.note.post.noteActions.zapped); - }, + onConfirm: onConfirmZap, + onSuccess: onSuccessZap, + onFail: onFailZap, + onCancel: onCancelZap, }; const startZap = (e: MouseEvent | TouchEvent) => { @@ -246,9 +261,6 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = } }; - const [zappedNow, setZappedNow] = createSignal(false); - const [zappedAmount, setZappedAmount] = createSignal(0); - const animateZap = () => { setShowZapAnim(true); setTimeout(() => { @@ -359,12 +371,6 @@ const NoteFooter: Component<{ note: PrimalNote, wide?: boolean, id?: string }> = }); - const [showZapAnim, setShowZapAnim] = createSignal(false); - const [hideZapIcon, setHideZapIcon] = createSignal(false); - - - let repostMenu: HTMLDivElement | undefined; - const determineOrient = () => { const coor = getScreenCordinates(repostMenu); const height = 100; diff --git a/src/constants.ts b/src/constants.ts index d14fb5f..8b4f140 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -107,6 +107,7 @@ export enum Kind { MuteList = 10_000, RelayList = 10_002, + Bookmarks = 10_003, CategorizedPeople = 30_000, Settings = 30_078, diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx index 90f7d8f..f3e3c35 100644 --- a/src/contexts/AccountContext.tsx +++ b/src/contexts/AccountContext.tsx @@ -28,11 +28,10 @@ import { sendContacts, sendLike, sendMuteList, triggerImportEvents } from "../li // @ts-ignore Bad types in nostr-tools import { generatePrivateKey, Relay, getPublicKey as nostrGetPubkey, nip19 } from "nostr-tools"; import { APP_ID } from "../App"; -import { getLikes, getFilterlists, getProfileContactList, getProfileMuteList, getUserProfiles, sendFilterlists, getAllowlist, sendAllowList, getRelays, sendRelays, extractRelayConfigFromTags } from "../lib/profile"; -import { clearSec, getStorage, getStoredProfile, readEmojiHistory, readSecFromStorage, saveEmojiHistory, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings, setStoredProfile, storeSec } from "../lib/localStore"; +import { getLikes, getFilterlists, getProfileContactList, getProfileMuteList, getUserProfiles, sendFilterlists, getAllowlist, sendAllowList, getRelays, sendRelays, extractRelayConfigFromTags, getBookmarks } from "../lib/profile"; +import { clearSec, getStorage, getStoredProfile, readBookmarks, readEmojiHistory, readSecFromStorage, saveBookmarks, saveEmojiHistory, saveFollowing, saveLikes, saveMuted, saveMuteList, saveRelaySettings, setStoredProfile, storeSec } from "../lib/localStore"; import { connectRelays, connectToRelay, getDefaultRelays, getPreConfiguredRelays } from "../lib/relays"; import { getPublicKey } from "../lib/nostrAPI"; -import { generateKeys } from "../lib/PrimalNostr"; import EnterPinModal from "../components/EnterPinModal/EnterPinModal"; import CreateAccountModal from "../components/CreateAccountModal/CreateAccountModal"; import LoginModal from "../components/LoginModal/LoginModal"; @@ -43,6 +42,7 @@ import { account as tAccount, followWarning, forgotPin } from "../translations"; import { getMembershipStatus } from "../lib/membership"; import ConfirmModal from "../components/ConfirmModal/ConfirmModal"; + export type AccountContextStore = { likes: string[], defaultRelays: string[], @@ -74,6 +74,7 @@ export type AccountContextStore = { showLogin: boolean, emojiHistory: EmojiOption[], membershipStatus: MembershipStatus, + bookmarks: string[], actions: { showNewNoteForm: () => void, hideNewNoteForm: () => void, @@ -101,6 +102,8 @@ export type AccountContextStore = { showGetStarted: () => void, saveEmoji: (emoji: EmojiOption) => void, checkNostrKey: () => void, + fetchBookmarks: () => void, + updateBookmarks: (bookmarks: string[]) => void, }, } @@ -134,6 +137,7 @@ const initialData = { showLogin: false, emojiHistory: [], membershipStatus: {}, + bookmarks: [], }; export const AccountContext = createContext(); @@ -285,6 +289,10 @@ export function AccountProvider(props: { children: JSXElement }) { updateStore('publicKey', () => pubkey); localStorage.setItem('pubkey', pubkey); checkMembershipStatus(); + + const bks = readBookmarks(pubkey); + updateStore('bookmarks', () => [...bks]); + fetchBookmarks(); } else { updateStore('publicKey', () => undefined); @@ -1301,10 +1309,19 @@ export function AccountProvider(props: { children: JSXElement }) { }; const checkNostrKey = () => { + if (store.publicKey) return; updateStore('isKeyLookupDone', () => false); fetchNostrKey(); }; + const fetchBookmarks = () => { + getBookmarks(store.publicKey, `user_bookmarks_${APP_ID}`); + } + + const updateBookmarks = (bookmarks: string[]) => { + updateStore('bookmarks', () => [...bookmarks]); + }; + // EFFECTS -------------------------------------- createEffect(() => { @@ -1483,6 +1500,29 @@ export function AccountProvider(props: { children: JSXElement }) { } } + if (subId === `user_bookmarks_${APP_ID}`) { + if (type === 'EVENT' && content && content.kind === Kind.Bookmarks) { + if (!content.created_at || content.created_at < store.followingSince) { + return; + } + + const notes = content.tags.reduce((acc, t) => { + if (t[0] === 'e') { + return [...acc, t[1]]; + } + return [...acc]; + }, []); + + updateStore('bookmarks', () => [...notes]); + } + + return; + } + + if (type === 'EOSE') { + saveBookmarks(store.publicKey, store.bookmarks); + } + }; // STORES --------------------------------------- @@ -1517,6 +1557,8 @@ const [store, updateStore] = createStore({ showGetStarted, saveEmoji, checkNostrKey, + fetchBookmarks, + updateBookmarks, }, }); diff --git a/src/lib/feed.ts b/src/lib/feed.ts index 10fc937..9dfaa91 100644 --- a/src/lib/feed.ts +++ b/src/lib/feed.ts @@ -67,19 +67,21 @@ export const getEvents = (user_pubkey: string | undefined, eventIds: string[], s }; -export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies', until = 0, limit = 20) => { +export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies' | 'bookmarks', until = 0, limit = 20, offset = 0) => { if (!pubkey) { return; } - const start = until === 0 ? 'since' : 'until'; - - let payload = { pubkey, limit, notes, [start]: until } ; + let payload = { pubkey, limit, notes } ; if (user_pubkey) { payload.user_pubkey = user_pubkey; } + if (until > 0) payload.until = until; + + if (offset > 0) payload.offset = offset; + sendMessage(JSON.stringify([ "REQ", subid, diff --git a/src/lib/localStore.ts b/src/lib/localStore.ts index 91dd068..c4b02dc 100644 --- a/src/lib/localStore.ts +++ b/src/lib/localStore.ts @@ -13,6 +13,7 @@ export type LocalStore = { theme: string, homeSidebarSelection: SelectionOption | undefined, userProfile: PrimalUser | undefined, + bookmarks: string[], recomended: { profiles: PrimalUser[], stats: Record, @@ -63,6 +64,7 @@ export const emptyStorage: LocalStore = { noteDraftUserRefs: {}, uploadTime: defaultUploadTime, selectedFeed: undefined, + bookmarks: [], } export const storageName = (pubkey?: string) => { @@ -423,3 +425,21 @@ export const saveStoredFeed = (pubkey: string | undefined, feed: PrimalFeed) => setStorage(pubkey, store); }; + +export const saveBookmarks = (pubkey: string | undefined, bookmarks: string[]) => { + if (!pubkey) return; + + const store = getStorage(pubkey); + + store.bookmarks = [ ...bookmarks ]; + + setStorage(pubkey, store); +}; + +export const readBookmarks = (pubkey: string | undefined) => { + if (!pubkey) return []; + + const store = getStorage(pubkey) + + return store.bookmarks || []; +}; diff --git a/src/lib/profile.ts b/src/lib/profile.ts index bf21dcf..a222216 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -376,6 +376,27 @@ export const sendRelays = async (relays: Relay[], relaySettings: NostrRelays) => return await sendEvent(event, relays, relaySettings); }; +export const sendBookmarks = async (tags: string[][], date: number, content: string, relays: Relay[], relaySettings?: NostrRelays) => { + const event = { + content, + kind: Kind.Bookmarks, + tags: [...tags], + created_at: date, + }; + + return await sendEvent(event, relays, relaySettings); +}; + +export const getBookmarks = async (pubkey: string | undefined, subid: string) => { + if (!pubkey) return; + + sendMessage(JSON.stringify([ + "REQ", + subid, + {cache: ["get_bookmarks", { pubkey }]}, + ])); +}; + export const extractRelayConfigFromTags = (tags: string[][]) => { return tags.reduce((acc, tag) => { if (tag[0] !== 'r') return acc; diff --git a/src/pages/Bookmarks.module.scss b/src/pages/Bookmarks.module.scss new file mode 100644 index 0000000..7847259 --- /dev/null +++ b/src/pages/Bookmarks.module.scss @@ -0,0 +1,26 @@ +.bookmarkFeed { + border-top: 1px solid var(--devider); + padding-top: 20px; + margin-bottom: 48px; +} + +.loader { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 64px; + margin-top: 20px; +} + +.noBookmarks { + font-weight: 400; + font-size: 20px; + line-height: 20px; + color: var(--text-secondary); + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} diff --git a/src/pages/Bookmarks.tsx b/src/pages/Bookmarks.tsx new file mode 100644 index 0000000..2194ea5 --- /dev/null +++ b/src/pages/Bookmarks.tsx @@ -0,0 +1,278 @@ +import { useIntl } from '@cookbook/solid-intl'; +import { Component, createEffect, For, on, onCleanup, onMount, Show, untrack } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { APP_ID } from '../App'; +import Loader from '../components/Loader/Loader'; +import Note from '../components/Note/Note'; +import PageCaption from '../components/PageCaption/PageCaption'; +import PageTitle from '../components/PageTitle/PageTitle'; +import Paginator from '../components/Paginator/Paginator'; +import { Kind } from '../constants'; +import { useAccountContext } from '../contexts/AccountContext'; +import { getEvents, getUserFeed } from '../lib/feed'; +import { setLinkPreviews } from '../lib/notes'; +import { subscribeTo } from '../sockets'; +import { convertToNotes, parseEmptyReposts } from '../stores/note'; +import { bookmarks as tBookmarks } from '../translations'; +import { NostrEventContent, NostrUserContent, NostrNoteContent, NostrStatsContent, NostrMentionContent, NostrNoteActionsContent, NoteActions, FeedPage, PrimalNote, NostrFeedRange, PageRange } from '../types/primal'; +import styles from './Bookmarks.module.scss'; + +export type BookmarkStore = { + fetchingInProgress: boolean, + page: FeedPage, + notes: PrimalNote[], + noteIds: string[], + offset: number, + pageRange: PageRange, + reposts: Record | undefined, + firstLoad: boolean, +} + +const emptyStore: BookmarkStore = { + fetchingInProgress: false, + page: { + messages: [], + users: {}, + postStats: {}, + mentions: {}, + noteActions: {}, + }, + notes: [], + noteIds: [], + pageRange: { + since: 0, + until: 0, + order_by: 'created_at', + }, + reposts: {}, + offset: 0, + firstLoad: true, +}; + +let since: number = 0; + +const Bookmarks: Component = () => { + const account = useAccountContext(); + const intl = useIntl(); + + const pageSize = 20; + + const [store, updateStore] = createStore({ ...emptyStore }); + + createEffect(on(() => account?.isKeyLookupDone, (v) => { + if (v && account?.publicKey) { + updateStore(() => ({ ...emptyStore })); + fetchBookmarks(account.publicKey); + } + })); + + onCleanup(() => { + updateStore(() => ({ ...emptyStore })); + }); + + const fetchBookmarks = (pubkey: string | undefined, until = 0) => { + if (store.fetchingInProgress || !pubkey) return; + + const subId = `bookmark_feed_${until}_${APP_ID}`; + + const unsub = subscribeTo(subId, (type, _, content) => { + if (type === 'EOSE') { + const reposts = parseEmptyReposts(store.page); + const ids = Object.keys(reposts); + + if (ids.length === 0) { + savePage(store.page); + unsub(); + return; + } + + updateStore('reposts', () => reposts); + + fetchReposts(ids); + + unsub(); + return; + } + + if (type === 'EVENT') { + content && updatePage(content); + } + }); + + updateStore('fetchingInProgress', () => true); + getUserFeed(pubkey, pubkey, subId, 'bookmarks', until, pageSize, store.offset); + } + + const fetchNextPage = () => since > 0 && fetchBookmarks(account?.publicKey, since); + + const fetchReposts = (ids: string[]) => { + const subId = `bookmark_reposts_${APP_ID}`; + + const unsub = subscribeTo(subId, (type, _, content) => { + if (type === 'EOSE') { + savePage(store.page); + unsub(); + return; + } + + if (type === 'EVENT') { + const repostId = (content as NostrNoteContent).id; + const reposts = store.reposts || {}; + const parent = store.page.messages.find(m => m.id === reposts[repostId]); + + if (parent) { + updateStore('page', 'messages', (msg) => msg.id === parent.id, 'content', () => JSON.stringify(content)); + } + + return; + } + }); + + getEvents(account?.publicKey, ids, subId); + }; + + const updatePage = (content: NostrEventContent) => { + if (content.kind === Kind.Metadata) { + const user = content as NostrUserContent; + + updateStore('page', 'users', + (usrs) => ({ ...usrs, [user.pubkey]: { ...user } }) + ); + return; + } + + if ([Kind.Text, Kind.Repost].includes(content.kind)) { + const message = content as NostrNoteContent; + + updateStore('page', 'messages', + (msgs) => [ ...msgs, { ...message }] + ); + + return; + } + + if (content.kind === Kind.NoteStats) { + const statistic = content as NostrStatsContent; + const stat = JSON.parse(statistic.content); + + updateStore('page', 'postStats', + (stats) => ({ ...stats, [stat.event_id]: { ...stat } }) + ); + return; + } + + if (content.kind === Kind.Mentions) { + const mentionContent = content as NostrMentionContent; + const mention = JSON.parse(mentionContent.content); + + updateStore('page', 'mentions', + (mentions) => ({ ...mentions, [mention.id]: { ...mention } }) + ); + return; + } + + if (content.kind === Kind.NoteActions) { + const noteActionContent = content as NostrNoteActionsContent; + const noteActions = JSON.parse(noteActionContent.content) as NoteActions; + + updateStore('page', 'noteActions', + (actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } }) + ); + return; + } + + if (content.kind === Kind.FeedRange) { + const noteActionContent = content as NostrFeedRange; + const range = JSON.parse(noteActionContent.content) as PageRange; + + updateStore('pageRange', () => ({ ...range })); + since = range.until; + return; + } + + if (content.kind === Kind.LinkMetadata) { + const metadata = JSON.parse(content.content); + + const data = metadata.resources[0]; + if (!data) { + return; + } + + const preview = { + url: data.url, + title: data.md_title, + description: data.md_description, + mediaType: data.mimetype, + contentType: data.mimetype, + images: [data.md_image], + favicons: [data.icon_url], + }; + + setLinkPreviews(() => ({ [data.url]: preview })); + return; + } + }; + + const savePage = (page: FeedPage) => { + const newPosts = convertToNotes(page); + + saveNotes(newPosts); + }; + + const saveNotes = (newNotes: PrimalNote[]) => { + const notesToAdd = newNotes.filter(n => !store.noteIds.includes(n.post.id)); + + const lastTimestamp = store.pageRange.since; + const offset = notesToAdd.reduce((acc, n) => n.post.created_at === lastTimestamp ? acc+1 : acc, 0); + + const ids = notesToAdd.map(m => m.post.id) + + ids.length > 0 && updateStore('noteIds', () => [...ids]); + + updateStore('offset', () => offset); + updateStore('notes', (notes) => [ ...notes, ...notesToAdd ]); + updateStore('page', () => ({ + messages: [], + users: {}, + postStats: {}, + mentions: {}, + noteActions: {}, + })); + updateStore('fetchingInProgress', () => false); + }; + + return ( + <> + + + + +
+ + +
+ {intl.formatMessage(tBookmarks.noBookmarks)} +
+
+ + + {(note) => + + } + + + + + +
+ {} +
+
+
+ + ); +} + +export default Bookmarks; diff --git a/src/palette.scss b/src/palette.scss index b7dcf51..8bf1040 100644 --- a/src/palette.scss +++ b/src/palette.scss @@ -34,6 +34,7 @@ --active-zap: #ffa02f; --active-liked: #f800c1; --active-reposted: #66e205; + --active-bookmarked: #0C7DD8; --background-modal: #00000070; --profile-indicator-border: var(--background-site); @@ -104,6 +105,7 @@ --active-zap: #ffa02f; --active-liked: #f800c1; --active-reposted: #66e205; + --active-bookmarked: #0C7DD8; --background-modal: #00000070; --profile-indicator-border: var(--background-site); @@ -173,6 +175,7 @@ --active-zap: #ffa02f; --active-liked: #CA079F; --active-reposted: #52CE0A; + --active-bookmarked: #0C7DD8; --background-modal: #f5f5f570; --profile-indicator-border: var(--accent); @@ -243,6 +246,7 @@ --active-zap: #ffa02f; --active-liked: #CA079F; --active-reposted: #52CE0A; + --active-bookmarked: #0C7DD8; --background-modal: #f5f5f570; --profile-indicator-border: var(--accent); diff --git a/src/translations.ts b/src/translations.ts index acc3830..4ecfa8b 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -714,6 +714,11 @@ export const navBar = { defaultMessage: 'Messages', description: 'Label for the nav bar item link to Messages page', }, + bookmarks: { + id: 'navbar.bookmarks', + defaultMessage: 'Bookmarks', + description: 'Label for the nav bar item link to Bookmarks page', + }, notifications: { id: 'navbar.notifications', defaultMessage: 'Notifications', @@ -2070,7 +2075,7 @@ export const followWarning = { description: { id: 'followWarning.description', defaultMessage: 'If you continue, you will end up following just one nostr account. Are you sure you want to continue?', - description: 'Explanation of what happens when follow erro occurs', + description: 'Explanation of what happens when follow error occurs', }, confirm: { id: 'followWarning.confirm', @@ -2084,6 +2089,61 @@ export const followWarning = { }, }; +export const bookmarks = { + pageTitle: { + id: 'bookmarks.pageTitle', + defaultMessage: 'Bookmarks', + description: 'Bookmarks page title', + }, + noBookmarks: { + id: 'bookmarks.noBookmarks', + defaultMessage: 'You don\'t have any bookmarks', + description: 'No bookmarks caption', + }, + confirm: { + title: { + id: 'bookmarks.confirm.title', + defaultMessage: 'Saving First Bookmark', + description: 'Follow error modal title', + }, + description: { + id: 'bookmarks.confirm.description', + defaultMessage: 'You are about to save your first public bookmark. These bookmarks can be seen by other nostr users. Do you wish to continue?', + description: 'Explanation of what happens when bookmark error occurs', + }, + confirm: { + id: 'bookmarks.confirm.confirm', + defaultMessage: 'Save Bookmark', + description: 'Confirm forgot pin action', + }, + abort: { + id: 'bookmarks.confirm.abort', + defaultMessage: 'Cancel', + description: 'Abort forgot pin action', + }, + titleZero: { + id: 'bookmarks.confirm.title', + defaultMessage: 'Removing Last Bookmark', + description: 'Follow error modal title', + }, + descriptionZero: { + id: 'bookmarks.confirm.description', + defaultMessage: 'You are about to remove your last public bookmark. Do you wish to continue?', + description: 'Explanation of what happens when bookmark error occurs', + }, + confirmZero: { + id: 'bookmarks.confirm.confirm', + defaultMessage: 'Remove Bookmark', + description: 'Confirm forgot pin action', + }, + abortZero: { + id: 'bookmarks.confirm.abort', + defaultMessage: 'Cancel', + description: 'Abort forgot pin action', + }, + } +} + export const lnInvoice = { pay: { id: 'lnInvoice.pay', diff --git a/src/types/primal.d.ts b/src/types/primal.d.ts index a959a24..075ce9c 100644 --- a/src/types/primal.d.ts +++ b/src/types/primal.d.ts @@ -200,6 +200,13 @@ export type PrimalUserRelays = { tags: string[][], }; +export type NostrBookmarks = { + kind: Kind.Bookmarks, + content: string, + created_at?: number, + tags: string[][], +}; + export type NostrEventContent = NostrNoteContent | NostrUserContent | @@ -227,7 +234,8 @@ export type NostrEventContent = NostrUserFollwerCounts | NostrUserZaps | NostrSuggestedUsers | - PrimalUserRelays; + PrimalUserRelays | + NostrBookmarks; export type NostrEvent = [ type: "EVENT", @@ -703,3 +711,9 @@ export type LnbcInvoice = { expiry: number, route_hints: LnbcRouteHint[], }; + +export type PageRange = { + since: number, + until: number, + order_by: string, +};