diff --git a/packages/app/src/api/events/workspace/index.tsx b/packages/app/src/api/events/workspace/index.tsx index 7423fd36a..6fc702b3d 100644 --- a/packages/app/src/api/events/workspace/index.tsx +++ b/packages/app/src/api/events/workspace/index.tsx @@ -5,6 +5,7 @@ export enum WorkspaceEvents { NOTE_UPDATED = 'noteUpdated', NOTE_EDITED = 'noteChanged', NOTE_HISTORY_UPDATED = 'noteHistoryUpdated', + NOTE_META_UPDATED = 'noteMetaChanged', } /** @@ -24,6 +25,13 @@ export type WorkspaceEventsPayloadMap = { */ [WorkspaceEvents.NOTE_UPDATED]: NoteId; + /** + * Fired when specific note metadata has been updated and change is committed in DB + * + * Metadata includes all note properties except the title and content + */ + [WorkspaceEvents.NOTE_META_UPDATED]: NoteId; + /** * Fired when note has been edited by user and change is committed in DB */ diff --git a/packages/app/src/components/NotePreview/NotePreview.theme.ts b/packages/app/src/components/NotePreview/NotePreview.theme.ts index d3a8f8541..70fca6a13 100644 --- a/packages/app/src/components/NotePreview/NotePreview.theme.ts +++ b/packages/app/src/components/NotePreview/NotePreview.theme.ts @@ -1,7 +1,7 @@ import { defineSlotRecipe } from '@chakra-ui/react'; export const notePreviewRecipe = defineSlotRecipe({ - slots: ['root', 'body', 'title', 'text', 'meta'], + slots: ['root', 'body', 'title', 'text', 'meta', 'header', 'icon'], base: { root: { cursor: 'pointer', @@ -16,11 +16,20 @@ export const notePreviewRecipe = defineSlotRecipe({ gap: '0.2rem', alignItems: 'normal', maxWidth: '100%', + width: '100%', + }, + header: { + width: '100%', + justifyContent: 'space-between', }, title: { fontWeight: 'bold', fontSize: '18px', }, + icon: { + color: 'typography.secondary', + fontSize: '12px', + }, text: { fontSize: '14px', maxWidth: '100%', diff --git a/packages/app/src/components/NotePreview/NotePreview.tsx b/packages/app/src/components/NotePreview/NotePreview.tsx index d2344cdcb..42c20e133 100644 --- a/packages/app/src/components/NotePreview/NotePreview.tsx +++ b/packages/app/src/components/NotePreview/NotePreview.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, memo, useMemo } from 'react'; +import { FaThumbtack } from 'react-icons/fa6'; import { isEqual } from 'lodash'; -import { Box, Stack, StackProps, Text, useSlotRecipe } from '@chakra-ui/react'; +import { Box, HStack, Stack, StackProps, Text, useSlotRecipe } from '@chakra-ui/react'; import { useLocalizedDate } from '@hooks/useLocalizedDate'; import { TextSample } from './TextSample'; @@ -15,11 +16,13 @@ export const NotePreviewContent = memo( text, textToHighlight, meta, + isPinned, }: { title: string; text: string; meta?: NotePreviewMeta; textToHighlight?: string; + isPinned?: boolean; }) => { const recipe = useSlotRecipe({ key: 'notePreview' }); const styles = recipe(); @@ -28,13 +31,23 @@ export const NotePreviewContent = memo( return ( <> - - - + + + + + + {isPinned && ( + + )} + {text.length > 0 ? ( @@ -68,8 +81,9 @@ export const NotePreview = forwardRef< meta?: NotePreviewMeta; isSelected?: boolean; textToHighlight?: string; + isPinned?: boolean; } & StackProps ->(({ title, text, textToHighlight, meta, isSelected, ...props }, ref) => { +>(({ title, text, textToHighlight, meta, isSelected, isPinned, ...props }, ref) => { const recipe = useSlotRecipe({ key: 'notePreview' }); const styles = recipe(); @@ -82,7 +96,7 @@ export const NotePreview = forwardRef< return ( - + ); }); diff --git a/packages/app/src/core/database/sqlite/migrations/index.ts b/packages/app/src/core/database/sqlite/migrations/index.ts index bd9bb52c3..c78a15d2e 100644 --- a/packages/app/src/core/database/sqlite/migrations/index.ts +++ b/packages/app/src/core/database/sqlite/migrations/index.ts @@ -33,4 +33,8 @@ export async function convertSQLToMigrationObject( export const getMigrationsList = async (): Promise => Promise.all([ convertSQLToMigrationObject('1_init_db', import('./sql/1_init_db.sql')), + convertSQLToMigrationObject( + '2_add_pinned_to_notes', + import('./sql/2_add_pinned_to_notes.sql'), + ), ]); diff --git a/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql b/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql new file mode 100644 index 000000000..3754342bc --- /dev/null +++ b/packages/app/src/core/database/sqlite/migrations/sql/2_add_pinned_to_notes.sql @@ -0,0 +1,7 @@ +ALTER TABLE notes +ADD COLUMN pinned_at INTEGER; + +DROP INDEX idx_notes_workspace_status; + +CREATE INDEX idx_notes_workspace_status + ON notes(workspace_id, deleted_at, archived, bookmarked, visible, pinned_at, updated_at DESC); \ No newline at end of file diff --git a/packages/app/src/core/features/notes/bin/DeletedNotesController.ts b/packages/app/src/core/features/notes/bin/DeletedNotesController.ts index f4159e752..2e741b00e 100644 --- a/packages/app/src/core/features/notes/bin/DeletedNotesController.ts +++ b/packages/app/src/core/features/notes/bin/DeletedNotesController.ts @@ -113,10 +113,12 @@ export class DeletedNotesController { return notes .get({ meta: { isDeleted: true }, - sort: { - by: considerModificationTime ? 'updatedAt' : 'deletedAt', - order: 'asc', - }, + sort: [ + { + by: considerModificationTime ? 'updatedAt' : 'deletedAt', + order: 'asc', + }, + ], limit: 1, }) .then((notes) => { diff --git a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts index f376e4536..b256ef056 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.filters.test.ts @@ -27,7 +27,7 @@ test('filter by update time', async () => { await expect( notes.get({ updatedAt: { from: new Date('01/01/2002 12:00') }, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }), ).resolves.toEqual([ expect.objectContaining({ content: expect.objectContaining({ title: '2002' }) }), @@ -37,7 +37,7 @@ test('filter by update time', async () => { await expect( notes.get({ updatedAt: { to: new Date('01/01/2002 12:00') }, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }), ).resolves.toEqual([ expect.objectContaining({ content: expect.objectContaining({ title: '2001' }) }), @@ -286,6 +286,23 @@ describe('data fetching', () => { ).resolves.toHaveLength(notesSample.length - 40); }); + test('filters notes by pinned status', async () => { + const { db, workspaceId } = getWorkspaceContext(); + const registry = new NotesController(db, workspaceId); + + const notesId = await registry + .get({ limit: 60 }) + .then((notes) => notes.map((note) => note.id)); + + await registry.updateMeta(notesId, { isPinned: true }); + await expect(registry.get({ meta: { isPinned: true } })).resolves.toHaveLength( + 60, + ); + await expect(registry.get({ meta: { isPinned: false } })).resolves.toHaveLength( + notesSample.length - 60, + ); + }); + test('method getLength consider filters', async () => { const { db, workspaceId } = getWorkspaceContext(); const registry = new NotesController(db, workspaceId); diff --git a/packages/app/src/core/features/notes/controller/NotesController.sort.test.ts b/packages/app/src/core/features/notes/controller/NotesController.sort.test.ts new file mode 100644 index 000000000..39a8af1be --- /dev/null +++ b/packages/app/src/core/features/notes/controller/NotesController.sort.test.ts @@ -0,0 +1,79 @@ +import { openSQLite } from '@core/database/sqlite/openSQLite'; +import { createWorkspaceId } from '@tests/utils/vaultContext'; +import { createFileControllerMock } from '@utils/mocks/fileControllerMock'; + +import { NotesController } from './NotesController'; + +test('updating a pinned note does not affect the order of pinned notes', async () => { + const db = await openSQLite(createFileControllerMock()); + onTestFinished(db.close); + + const workspaceId = await createWorkspaceId(db.get()); + const registry = new NotesController(db.get(), workspaceId); + + const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); + const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); + const note3 = await registry.add({ title: '2003', text: 'Dummy text' }); + + // Pin + vi.setSystemTime('01/01/2010 12:00'); + await registry.updateMeta([note1], { isPinned: true }); + vi.setSystemTime('01/01/2011 12:00'); + await registry.updateMeta([note3], { isPinned: true }); + + await expect( + registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), + ).resolves.toStrictEqual([note3, note1, note2]); + + // Updating note content in a pinned note should not affect the order + vi.setSystemTime('01/01/2015 12:00'); + await registry.update(note1, { title: '2015', text: 'Dummy text' }); + + await expect( + registry.query({ sort: [{ by: 'pinnedAt', order: 'desc' }] }), + ).resolves.toStrictEqual([note3, note1, note2]); +}); + +test('updating an unpinned note does not affect the order of pinned notes', async () => { + const db = await openSQLite(createFileControllerMock()); + onTestFinished(db.close); + + const workspaceId = await createWorkspaceId(db.get()); + const registry = new NotesController(db.get(), workspaceId); + + vi.setSystemTime('01/01/2000 12:00'); + const note1 = await registry.add({ title: '2001', text: 'Dummy text' }); + + vi.setSystemTime('01/01/2002 12:00'); + const note2 = await registry.add({ title: '2002', text: 'Dummy text' }); + + vi.setSystemTime('01/01/2004 12:00'); + const note3 = await registry.add({ title: '2004', text: 'Dummy text' }); + + // Pin note1 + vi.setSystemTime('01/01/2010 12:00'); + await registry.updateMeta([note1], { isPinned: true }); + + // Pinned notes come first, unpinned notes are sorted by update time + await expect( + registry.query({ + sort: [ + { by: 'pinnedAt', order: 'desc' }, + { by: 'updatedAt', order: 'desc' }, + ], + }), + ).resolves.toStrictEqual([note1, note3, note2]); + + // Update an unpinned note2 + vi.setSystemTime('01/01/2015 12:00'); + await registry.update(note2, { title: '2015', text: 'Dummy text' }); + + await expect( + registry.query({ + sort: [ + { by: 'pinnedAt', order: 'desc' }, + { by: 'updatedAt', order: 'desc' }, + ], + }), + ).resolves.toStrictEqual([note1, note2, note3]); +}); diff --git a/packages/app/src/core/features/notes/controller/NotesController.test.ts b/packages/app/src/core/features/notes/controller/NotesController.test.ts index 57aa7fd2b..21620f66f 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.test.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.test.ts @@ -194,14 +194,14 @@ test('Get notes by pages', async () => { const page1 = await registry.get({ limit: 100, page: 1, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }); expect(page1[0].content).toMatchObject(notesSample[0]); const page2 = await registry.get({ limit: 100, page: 2, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }); expect(page2[0].content).toMatchObject(notesSample[100]); }); @@ -408,6 +408,35 @@ describe('Meta data control', () => { }, ]); }); + + test('toggle note pinned status', async () => { + const { db, workspaceId } = getWorkspaceContext(); + const registry = new NotesController(db, workspaceId); + + const noteId = await registry.add({ title: 'Title', text: 'Text' }); + await expect(registry.getById([noteId])).resolves.toMatchObject([ + { + id: noteId, + isPinned: false, + }, + ]); + + await registry.updateMeta([noteId], { isPinned: true }); + await expect(registry.getById([noteId])).resolves.toMatchObject([ + { + id: noteId, + isPinned: true, + }, + ]); + + await registry.updateMeta([noteId], { isPinned: false }); + await expect(registry.getById([noteId])).resolves.toMatchObject([ + { + id: noteId, + isPinned: false, + }, + ]); + }); }); const getWorkspaceContext = createWorkspaceContext(); diff --git a/packages/app/src/core/features/notes/controller/NotesController.ts b/packages/app/src/core/features/notes/controller/NotesController.ts index 69433b379..c9a4d7787 100644 --- a/packages/app/src/core/features/notes/controller/NotesController.ts +++ b/packages/app/src/core/features/notes/controller/NotesController.ts @@ -29,6 +29,7 @@ const RowScheme = z deleted_at: z.coerce.date().nullable(), archived: z.coerce.boolean(), bookmarked: z.coerce.boolean(), + pinned_at: z.coerce.date().nullable(), }) .transform( ({ @@ -42,6 +43,7 @@ const RowScheme = z deleted_at, archived, bookmarked, + pinned_at, }): INote => ({ id, createdTimestamp: created_at.getTime(), @@ -52,6 +54,7 @@ const RowScheme = z deletedAt: deleted_at?.getTime(), isArchived: archived, isBookmarked: bookmarked, + isPinned: pinned_at !== null, content: { title, text }, }), ); @@ -80,6 +83,9 @@ function formatNoteMeta(meta: Partial) { case 'isBookmarked': fields['bookmarked'] = Number(value); break; + case 'isPinned': + fields['pinned_at'] = value ? new Date().getTime() : null; + break; } } @@ -98,7 +104,7 @@ function getFetchQuery( limit, page, tags = [], - meta: { isDeleted, ...meta } = {}, + meta: { isDeleted, isPinned, ...meta } = {}, deletedAt = {}, updatedAt = {}, sort, @@ -112,6 +118,7 @@ function getFetchQuery( createdAt: 'created_at', updatedAt: 'updated_at', deletedAt: 'deleted_at', + pinnedAt: 'pinned_at', }; const withQuery: { name: string; query: Query }[] = []; @@ -146,6 +153,11 @@ function getFetchQuery( qb.sql`deleted_at IS ${isDeleted ? qb.sql`NOT NULL` : qb.sql`NULL`}`, ); } + if (isPinned !== undefined) { + filterQuery.push( + qb.sql`pinned_at IS ${isPinned ? qb.sql`NOT NULL` : qb.sql`NULL`}`, + ); + } if (deletedAt.from) { filterQuery.push(qb.sql`deleted_at >= ${deletedAt.from.getTime()}`); @@ -164,7 +176,9 @@ function getFetchQuery( // Sort if (sort) { orderQuery.push( - qb.line(sortFieldMap[sort.by], sort.order === 'desc' ? 'DESC' : 'ASC'), + ...sort.map(({ by, order }) => + qb.line(sortFieldMap[by], order === 'desc' ? 'DESC' : 'ASC'), + ), ); } diff --git a/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts b/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts index b9c82f702..b8da0b6ff 100644 --- a/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts +++ b/packages/app/src/core/features/notes/controller/NotesTextIndexer.ts @@ -46,7 +46,7 @@ export class NotesTextIndexer { // Take notes chunk const notes = await this.notes .get({ - sort: { by: 'updatedAt', order: 'asc' }, + sort: [{ by: 'updatedAt', order: 'asc' }], updatedAt: { from: startDate ?? undefined }, limit: chunkSize, page, diff --git a/packages/app/src/core/features/notes/controller/index.ts b/packages/app/src/core/features/notes/controller/index.ts index cef2d9310..8fe25eb52 100644 --- a/packages/app/src/core/features/notes/controller/index.ts +++ b/packages/app/src/core/features/notes/controller/index.ts @@ -6,9 +6,10 @@ export type NoteMeta = { isDeleted: boolean; isArchived: boolean; isBookmarked: boolean; + isPinned: boolean; }; -export type NoteSortField = 'id' | 'createdAt' | 'updatedAt' | 'deletedAt'; +export type NoteSortField = 'id' | 'createdAt' | 'updatedAt' | 'deletedAt' | 'pinnedAt'; export type NoteContentUpdateInfo = { id: NoteId; @@ -74,7 +75,7 @@ export type NotesControllerFetchOptions = { sort?: { by: NoteSortField; order?: 'desc' | 'asc'; - }; + }[]; }; export type ControlledNoteMeta = Partial & { updatedAt?: number }; diff --git a/packages/app/src/core/features/telemetry/index.ts b/packages/app/src/core/features/telemetry/index.ts index f533bdbf0..aeafc74a1 100644 --- a/packages/app/src/core/features/telemetry/index.ts +++ b/packages/app/src/core/features/telemetry/index.ts @@ -28,6 +28,7 @@ export enum TELEMETRY_EVENT_NAME { NOTE_ARCHIVE_TOGGLE = 'Note archive toggled', NOTE_BOOKMARK_TOGGLE = 'Note bookmark toggled', + NOTE_PIN_TOGGLE = 'Note pin toggled', NOTE_TAG_ATTACHED = 'Tag attached', NOTE_TAG_DETACHED = 'Tag detached', diff --git a/packages/app/src/core/storage/interop/export/index.ts b/packages/app/src/core/storage/interop/export/index.ts index b17e5ad5c..84bc7bd1e 100644 --- a/packages/app/src/core/storage/interop/export/index.ts +++ b/packages/app/src/core/storage/interop/export/index.ts @@ -133,7 +133,7 @@ export class NotesExporter { const notes = await notesRegistry.get({ limit: this.config.notesFetchLimit ?? 10_000, page, - sort: { by: 'createdAt', order: 'asc' }, + sort: [{ by: 'createdAt', order: 'asc' }], }); // Complete when no more notes left diff --git a/packages/app/src/core/storage/interop/import/NotesImporter.test.ts b/packages/app/src/core/storage/interop/import/NotesImporter.test.ts index 7f4e2bc6a..ee9f15f0c 100644 --- a/packages/app/src/core/storage/interop/import/NotesImporter.test.ts +++ b/packages/app/src/core/storage/interop/import/NotesImporter.test.ts @@ -117,7 +117,7 @@ describe('Base notes import cases', () => { const notesRegistry = new NotesController(db, workspaceId); await expect( - notesRegistry.get({ sort: { by: 'createdAt', order: 'asc' } }), + notesRegistry.get({ sort: [{ by: 'createdAt', order: 'asc' }] }), ).resolves.toEqual([ expect.objectContaining({ // TODO: fix unnecessary new line @@ -823,7 +823,7 @@ describe('Import respects meta info', () => { const { notesRegistry } = deps; await expect( notesRegistry - .query({ sort: { by: 'createdAt' } }) + .query({ sort: [{ by: 'createdAt' }] }) .then((ids) => notesRegistry.getById(ids)), ).resolves.toMatchObject([ { diff --git a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx index fa4888173..9286ed3f1 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/NotesList.tsx @@ -91,6 +91,7 @@ export const NotesList: FC = () => { align: 'center', userSelect: 'none', paddingInlineEnd: '.3rem', + overflowAnchor: 'none', }} > {noteIds.length === 0 ? ( @@ -201,6 +202,7 @@ export const NotesList: FC = () => { }, ); }} + isPinned={note.isPinned} onDoubleClick={() => { // Convert preview tab to regular noteActions.click(note.id, { preview: false }); diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx index 1a49a0c88..377b33e0e 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useNotesData.tsx @@ -45,6 +45,15 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { useEffect(() => { const onNoteUpdated = (noteId: NoteId) => { if (notesData.has(noteId)) { + // Update the note immediately, otherwise it may temporarily display stale data + // e.g. after unpinning, the note moves but still shows the pinned icon + // until the cache is updated and it is re-rendered with the correct state + notesRegistry.getById([noteId]).then((loadedNote) => { + const note = loadedNote[0]; + if (!note) return; + notesData.add([[note.id, note]]); + }); + loadNotesData(); } }; @@ -53,7 +62,7 @@ export const useNotesData = ({ noteIds }: { noteIds: NoteId[] }) => { eventBus.listen(WorkspaceEvents.NOTE_UPDATED, onNoteUpdated), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); - }, [eventBus, loadNotesData, notesData]); + }, [eventBus, loadNotesData, notesData, notesRegistry]); return notesData; }; diff --git a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx index 07821e240..84e080ca3 100644 --- a/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx +++ b/packages/app/src/features/MainScreen/NotesListPanel/useScrollToActiveNote.tsx @@ -87,6 +87,7 @@ export const useScrollToActiveNote = ({ // Focus active note by changes in case it was in viewport const wasActiveNoteInViewport = useRef(false); + const skipScrollRef = useRef(false); const eventBus = useEventBus(); useEffect(() => { if (activeNoteId === null) return; @@ -99,7 +100,16 @@ export const useScrollToActiveNote = ({ }; return joinCallbacks( - eventBus.listen(WorkspaceEvents.NOTE_UPDATED, onNoteUpdated), + eventBus.listen(WorkspaceEvents.NOTE_META_UPDATED, (noteId) => { + if (noteId === activeNoteId) skipScrollRef.current = true; + }), + eventBus.listen(WorkspaceEvents.NOTE_UPDATED, (noteId) => { + if (skipScrollRef.current) { + skipScrollRef.current = false; + return; + } + onNoteUpdated(noteId); + }), eventBus.listen(WorkspaceEvents.NOTE_EDITED, onNoteUpdated), ); }, [activeNoteId, activeNoteRef, eventBus]); diff --git a/packages/app/src/features/NoteEditor/NoteMenu.tsx b/packages/app/src/features/NoteEditor/NoteMenu.tsx index e48dbf5dc..a0b96d51c 100644 --- a/packages/app/src/features/NoteEditor/NoteMenu.tsx +++ b/packages/app/src/features/NoteEditor/NoteMenu.tsx @@ -6,11 +6,12 @@ import { FaCopy, FaEllipsis, FaFileExport, + FaThumbtack, FaTrashCan, FaTrashCanArrowUp, } from 'react-icons/fa6'; import { LOCALE_NAMESPACE } from 'src/i18n'; -import { Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; +import { Box, Button, HStack, Menu, Portal, Text } from '@chakra-ui/react'; import { INote } from '@core/features/notes'; import { GLOBAL_COMMANDS } from '@hooks/commands'; import { useCommand } from '@hooks/commands/useCommand'; @@ -41,6 +42,29 @@ export const NoteMenu = memo(({ note }: { note: INote }) => { + + runCommand( + GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, + { + noteId: note.id, + }, + ) + } + value="togglePin" + > + + + + {note.isPinned + ? t('note.menu.unpin') + : t('note.menu.pin')} + + + diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts index e4ec07048..f485eb267 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/index.ts @@ -5,6 +5,7 @@ export enum NoteActions { DELETE_TO_BIN = 'deleteToBin', DELETE_PERMANENTLY = 'deletePermanently', RESTORE_FROM_BIN = 'restoreFromBin', + PIN_NOTE = 'pinNote', CLOSE = 'CLOSE', CLOSE_ALL = 'CLOSE_ALL', diff --git a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts index afa72795c..8439cfc0b 100644 --- a/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts +++ b/packages/app/src/features/NotesContainer/NoteContextMenu/useNoteContextMenu.ts @@ -48,6 +48,10 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { runCommand(GLOBAL_COMMANDS.RESTORE_NOTE_FROM_BIN, { noteId }); }, + [NoteActions.PIN_NOTE]: (noteId: string) => { + runCommand(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, { noteId }); + }, + [NoteActions.DUPLICATE]: (noteId: string) => { runCommand(GLOBAL_COMMANDS.DUPLICATE_NOTE, { noteId }); }, @@ -118,6 +122,18 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { ] as const) : []), + ...(context !== 'tabs' + ? ([ + { + id: NoteActions.PIN_NOTE, + label: note.isPinned + ? t('noteList.unpinNote') + : t('noteList.pinNote'), + }, + { type: 'separator' }, + ] as const) + : []), + ...(note.isDeleted ? [{ id: NoteActions.RESTORE_FROM_BIN, label: t('restoreFromBin') }] : []), @@ -130,6 +146,7 @@ export const useNoteContextMenu = (context?: 'tabs' | 'notes-list') => { id: NoteActions.EXPORT, label: t('export'), }, + { id: NoteActions.COPY_MARKDOWN_LINK, label: t('copyMarkdownLink'), diff --git a/packages/app/src/hooks/commands/index.ts b/packages/app/src/hooks/commands/index.ts index fae4c8225..dc160aaf7 100644 --- a/packages/app/src/hooks/commands/index.ts +++ b/packages/app/src/hooks/commands/index.ts @@ -27,6 +27,7 @@ export enum GLOBAL_COMMANDS { RESTORE_NOTE_FROM_BIN = 'Restore note from bin', TOGGLE_NOTE_ARCHIVE = 'Toggle note archive status', TOGGLE_NOTE_BOOKMARK = 'Toggle note bookmark status', + TOGGLE_NOTE_PIN = 'Toggle note pin status', /** * Open and close the history for a note @@ -55,6 +56,7 @@ type CommandsWithPayload = { [GLOBAL_COMMANDS.TOGGLE_NOTE_ARCHIVE]: { noteId: string }; [GLOBAL_COMMANDS.TOGGLE_NOTE_BOOKMARK]: { noteId: string }; + [GLOBAL_COMMANDS.TOGGLE_NOTE_PIN]: { noteId: string }; [GLOBAL_COMMANDS.TOGGLE_NOTE_HISTORY_PANEL]: { noteId: string }; [GLOBAL_COMMANDS.EXPORT_NOTE]: { noteId: string }; diff --git a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts index 5e77fe54c..c17396956 100644 --- a/packages/app/src/hooks/notes/useNoteCommandHandlers.ts +++ b/packages/app/src/hooks/notes/useNoteCommandHandlers.ts @@ -181,4 +181,24 @@ export const useNoteCommandHandlers = () => { }); }, ); + + useWorkspaceCommandCallback(GLOBAL_COMMANDS.TOGGLE_NOTE_PIN, async ({ noteId }) => { + const [note] = await notes.getById([noteId]); + if (!note) { + console.warn(`Not found note with id ${noteId}`); + return; + } + + const newPinnedState = !note.isPinned; + await notes.updateMeta([noteId], { + isPinned: newPinnedState, + }); + + eventBus.emit(WorkspaceEvents.NOTE_META_UPDATED, noteId); + eventBus.emit(WorkspaceEvents.NOTE_UPDATED, noteId); + + telemetry.track(TELEMETRY_EVENT_NAME.NOTE_PIN_TOGGLE, { + action: newPinnedState ? 'Pinned' : 'Unpinned', + }); + }); }; diff --git a/packages/app/src/hooks/notes/useUpdateNotes.ts b/packages/app/src/hooks/notes/useUpdateNotes.ts index ae3fb84e6..7ed0ea957 100644 --- a/packages/app/src/hooks/notes/useUpdateNotes.ts +++ b/packages/app/src/hooks/notes/useUpdateNotes.ts @@ -40,7 +40,15 @@ export const useUpdateNotes = () => { const noteIds = await notesRegistry.query({ tags, - sort: { by: 'updatedAt', order: 'desc' }, + sort: [ + ...(notesView !== NOTES_VIEW.BIN && searchText.length === 0 + ? [{ by: 'pinnedAt', order: 'desc' } as const] + : []), + { + by: 'updatedAt', + order: 'desc', + }, + ], search: searchText ? { text: searchText, diff --git a/packages/app/src/locales/en/features.json b/packages/app/src/locales/en/features.json index 92656b1f0..17fcb5574 100644 --- a/packages/app/src/locales/en/features.json +++ b/packages/app/src/locales/en/features.json @@ -116,7 +116,9 @@ "removeFromArchive": "Remove from archive", "deleteToBin": "Delete to bin", "deletePermanently": "Delete permanently", - "restoreFromBin": "Restore from bin" + "restoreFromBin": "Restore from bin", + "pin": "Pin note", + "unpin": "Unpin note" }, "version": { "chars": "{{count}} characters", diff --git a/packages/app/src/locales/en/menu.json b/packages/app/src/locales/en/menu.json index f7b33bbea..d4d5db752 100644 --- a/packages/app/src/locales/en/menu.json +++ b/packages/app/src/locales/en/menu.json @@ -24,6 +24,10 @@ "deletePermanently": "Delete permanently", "restoreFromBin": "Restore from bin", "deleteToBin": "Move to bin", + "noteList": { + "pinNote": "Pin note", + "unpinNote": "Unpin note" + }, "tab": { "close": "Close", "closeOthers": "Close Others",