Add bookmarks

This commit is contained in:
Bojan Mojsilovic 2024-03-29 15:07:54 +01:00
parent 4e15d19b40
commit 5b9df00aa9
21 changed files with 748 additions and 63 deletions

View File

@ -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 = () => {
<Route path="/network" component={Network} />
<Route path="/filters" component={Moderation} />
</Route>
<Route path="/bookmarks" component={Bookmarks}/>
<Route path="/settings/profile" component={EditProfile} />
<Route path="/profile/:npub?" component={Profile} />
<Route path="/p/:npub?" component={Profile} />

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6666 1.38697H4.33325C3.96506 1.38697 3.66659 1.69746 3.66659 2.08046V13.9669L7.13489 11.4202C7.65348 11.0394 8.34635 11.0394 8.86494 11.4202L12.3333 13.9669V2.08046C12.3333 1.69746 12.0348 1.38697 11.6666 1.38697ZM4.33325 0C3.22868 0 2.33325 0.931453 2.33325 2.08046V15.3053C2.33325 15.8676 2.94277 16.1961 3.38437 15.8719L7.9038 12.5533C7.96143 12.511 8.03841 12.511 8.09603 12.5533L12.6155 15.8719C13.0571 16.1961 13.6666 15.8676 13.6666 15.3053V2.08046C13.6666 0.931453 12.7712 0 11.6666 0H4.33325Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.33325 0C3.22868 0 2.33325 0.931453 2.33325 2.08046V15.3053C2.33325 15.8676 2.94277 16.1961 3.38437 15.8719L7.9038 12.5533C7.96143 12.511 8.03841 12.511 8.09603 12.5533L12.6155 15.8719C13.0571 16.1961 13.6666 15.8676 13.6666 15.3053V2.08046C13.6666 0.931453 12.7712 0 11.6666 0H4.33325Z" fill="#0C7DD8"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

View File

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

View File

@ -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 (
<div class={styles.bookmark}>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault();
doBookmark(isBookmarked());
}}
disabled={bookmarkInProgress()}
>
<Show
when={isBookmarked()}
fallback={
<div class={styles.emptyBookmark}></div>
}
>
<div class={styles.fullBookmark}></div>
</Show>
</ButtonGhost>
</div>
)
}
export default BookmarkNote;

View File

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

View File

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

View File

@ -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),

View File

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

View File

@ -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}
/>
<div class={styles.upRightFloater}>
<BookmarkNote note={props.note} />
</div>
<NoteReplyToHeader note={props.note} />
<div class={styles.message}>

View File

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

View File

@ -107,6 +107,7 @@ export enum Kind {
MuteList = 10_000,
RelayList = 10_002,
Bookmarks = 10_003,
CategorizedPeople = 30_000,
Settings = 30_078,

View File

@ -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<AccountContextStore>();
@ -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<AccountContextStore>({
showGetStarted,
saveEmoji,
checkNostrKey,
fetchBookmarks,
updateBookmarks,
},
});

View File

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

View File

@ -13,6 +13,7 @@ export type LocalStore = {
theme: string,
homeSidebarSelection: SelectionOption | undefined,
userProfile: PrimalUser | undefined,
bookmarks: string[],
recomended: {
profiles: PrimalUser[],
stats: Record<string, UserStats>,
@ -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 || [];
};

View File

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

View File

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

278
src/pages/Bookmarks.tsx Normal file
View File

@ -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<string, string> | 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<BookmarkStore>({ ...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<number>((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 (
<>
<PageTitle title={intl.formatMessage(tBookmarks.pageTitle)} />
<PageCaption title={intl.formatMessage(tBookmarks.pageTitle)} />
<div class={styles.bookmarkFeed}>
<Show when={!store.fetchingInProgress && store.notes.length === 0}>
<div class={styles.noBookmarks}>
{intl.formatMessage(tBookmarks.noBookmarks)}
</div>
</Show>
<For each={store.notes}>
{(note) =>
<Note note={note} />
}
</For>
<Paginator loadNextPage={fetchNextPage} />
<Show
when={store.fetchingInProgress}
>
<div class={styles.loader}>
{<Loader/>}
</div>
</Show>
</div>
</>
);
}
export default Bookmarks;

View File

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

View File

@ -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',

16
src/types/primal.d.ts vendored
View File

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