import React, { useMemo } from "react"; import { Button, Flex, Text } from "rebass"; import * as Icon from "../icons"; import TimeAgo from "../time-ago"; import ListItem from "../list-item"; import { confirm, showMoveNoteDialog } from "../../common/dialog-controller"; import { store, useStore } from "../../stores/note-store"; import { store as userstore } from "../../stores/user-store"; import { useStore as useAttachmentStore } from "../../stores/attachment-store"; import { db } from "../../common/db"; import { showUnpinnedToast } from "../../common/toasts"; import { showToast } from "../../utils/toast"; import { hashNavigate, navigate } from "../../navigation"; import { showPublishView } from "../publish-view"; import IconTag from "../icon-tag"; import { COLORS } from "../../common/constants"; import { exportNotes } from "../../common/export"; import { Multiselect } from "../../common/multi-select"; import { store as selectionStore } from "../../stores/selection-store"; function Note(props) { const { tags, notebook, item, index, context, date } = props; const note = item; const isOpened = useStore((store) => store.selectedNote === note.id); const isCompact = useStore((store) => store.viewMode === "compact"); const attachments = useAttachmentStore((store) => store.attachments.filter((a) => a.noteIds.includes(note.id)) ); const failed = useMemo( () => attachments.filter((a) => a.failed), [attachments] ); const primary = useMemo(() => { if (!note.color) return "primary"; return note.color.toLowerCase(); }, [note.color]); return ( { if (e.key === "Delete") { let selectedItems = selectionStore .get() .selectedItems.filter((i) => i.type === item.type && i !== item); await Multiselect.moveNotesToTrash([item, ...selectedItems]); } }} colors={{ primary, text: note.color ? primary : "text", background: isOpened ? "bgSecondary" : "background", }} menu={{ items: context?.type === "topic" ? topicNoteMenuItems : menuItems, extraData: { note, context }, }} onClick={() => { if (note.conflicted) { hashNavigate(`/notes/${note.id}/conflict`, { replace: true }); } else if (note.locked) { hashNavigate(`/notes/${note.id}/unlock`, { replace: true }); } else { hashNavigate(`/notes/${note.id}/edit`, { replace: true }); } }} header={ notebook && ( { navigate(`/notebooks/${notebook.id}/${notebook.topic.id}`); }} text={`${notebook.title} › ${notebook.topic.title}`} icon={Icon.Notebook} /> ) } footer={ {isCompact ? ( <> {note.conflicted && ( )} {note.locked && ( )} {note.favorite && ( )} ) : ( <> {note.conflicted && ( )} {note.localOnly && } {attachments.length > 0 && ( {attachments.length} )} {failed.length > 0 && ( {failed.length} )} {note.pinned && !props.context && ( )} {note.locked && ( )} {note.favorite && ( )} {tags?.map((tag) => { return ( ); })} )} } /> ); } export default React.memo(Note, function (prevProps, nextProps) { const prevItem = prevProps.item; const nextItem = nextProps.item; return ( prevProps.date === nextProps.date && prevItem.pinned === nextItem.pinned && prevItem.favorite === nextItem.favorite && prevItem.localOnly === nextItem.localOnly && prevItem.headline === nextItem.headline && prevItem.title === nextItem.title && prevItem.locked === nextItem.locked && prevItem.conflicted === nextItem.conflicted && prevItem.color === nextItem.color && prevProps.notebook?.dateEdited === nextProps.notebook?.dateEdited && JSON.stringify(prevProps.tags) === JSON.stringify(nextProps.tags) ); }); const pin = (note) => { return store .pin(note.id) .then(async () => { if (note.pinned) await showUnpinnedToast(note.id, "note"); }) .catch((error) => showToast("error", error.message)); }; const formats = [ { type: "pdf", title: "PDF", icon: Icon.PDF, subtitle: "Can be opened in any PDF reader like Adobe Acrobat or Foxit Reader.", }, { type: "md", title: "Markdown", icon: Icon.Markdown, subtitle: "Can be opened in any plain-text or markdown editor.", }, { type: "html", title: "HTML", icon: Icon.HTML, subtitle: "Can be opened in any web browser like Google Chrome.", }, { type: "txt", title: "Text", icon: Icon.Text, subtitle: "Can be opened in any plain-text editor.", }, ]; const notFullySyncedText = "Cannot perform this action because note is not fully synced."; const menuItems = [ { key: "pin", title: ({ note }) => (note.pinned ? "Unpin" : "Pin"), icon: Icon.Pin, onClick: async ({ note }) => { await pin(note); }, }, { key: "favorite", title: ({ note }) => (note.favorite ? "Unfavorite" : "Favorite"), icon: Icon.StarOutline, onClick: ({ note }) => store.favorite(note.id), }, { key: "addtonotebook", title: "Add to notebook(s)", icon: Icon.AddToNotebook, onClick: async ({ items }) => { await showMoveNoteDialog(items.map((i) => i.id)); }, multiSelect: true, }, { key: "colors", title: "Assign color", icon: Icon.Colors, items: colorsToMenuItems(), }, { key: "publish", disabled: ({ note }) => { if (!db.notes.note(note.id).synced()) return notFullySyncedText; if (!db.monographs.isPublished(note.id) && note.locked) return "You cannot publish a locked note."; }, icon: Icon.Publish, title: ({ note }) => db.monographs.isPublished(note.id) ? "Unpublish" : "Publish", onClick: async ({ note }) => { const isPublished = db.monographs.isPublished(note.id); if (isPublished) await db.monographs.unpublish(note.id); else await showPublishView(note.id, "bottom"); }, }, { key: "export", title: "Export as", icon: Icon.Export, disabled: ({ note }) => { if (!db.notes.note(note.id).synced()) return notFullySyncedText; if (note.locked) return "Locked notes cannot be exported currently."; }, items: formats.map((format) => ({ key: format.type, title: format.title, tooltip: `Export as ${format.title} - ${format.subtitle}`, icon: format.icon, disabled: ({ items }) => format.type === "pdf" && items.length > 1 ? "Multiple notes cannot be exported as PDF." : false, onClick: async ({ items }) => { await exportNotes( format.type, items.map((i) => i.id) ); }, })), multiSelect: true, isPro: true, }, { key: "duplicate", title: "Duplicate", disabled: ({ note }) => { if (!db.notes.note(note.id).synced()) return notFullySyncedText; if (note.locked) return "Locked notes cannot be duplicated"; }, icon: Icon.Duplicate, onClick: async ({ note }) => { const id = await store.get().duplicate(note); if ( await confirm({ title: "Open duplicated note?", message: "Do you want to open the duplicated note?", noText: "No", yesText: "Yes", }) ) { hashNavigate(`/notes/${id}/edit`, { replace: true }); } }, }, { key: "lock", disabled: ({ note }) => !db.notes.note(note.id).synced() ? notFullySyncedText : false, title: ({ note }) => (note.locked ? "Unlock" : "Lock"), icon: Icon.Lock, onClick: async ({ note }) => { const { unlock, lock } = store.get(); if (!note.locked) { if (await lock(note.id)) showToast("success", "Note locked successfully!"); } else { if (await unlock(note.id)) showToast("success", "Note unlocked successfully!"); } }, isPro: true, }, { key: "sync-disable", hidden: () => !userstore.get().isLoggedIn, disabled: ({ note }) => !db.notes.note(note.id).synced() ? notFullySyncedText : false, title: ({ note }) => (note.localOnly ? "Enable sync" : "Disable sync"), icon: ({ note }) => (note.localOnly ? Icon.Sync : Icon.SyncOff), onClick: async ({ note }) => { if ( note.localOnly || (await confirm({ title: "Disable sync for this item?", message: "Turning sync off for this item will automatically delete it from all other devices. Are you sure you want to continue?", yesText: "Yes", noText: "No", })) ) await store.get().localOnly(note.id); }, }, { key: "movetotrash", title: "Move to trash", color: "error", iconColor: "error", icon: Icon.Trash, disabled: ({ items }) => { const areAllSynced = items.reduce((prev, curr) => { if (!prev || !db.notes.note(curr.id).synced()) return false; return prev; }, true); if (!areAllSynced) return notFullySyncedText; if (items.length !== 1) return false; if (db.monographs.isPublished(items[0].id)) return "Please unpublish this note to move it to trash"; }, onClick: async ({ items }) => { await Multiselect.moveNotesToTrash(items, items.length > 1); }, multiSelect: true, }, ]; const topicNoteMenuItems = [ ...menuItems, { key: "removefromtopic", title: "Remove from topic", icon: Icon.TopicRemove, color: "error", iconColor: "error", onClick: async ({ items, context }) => { await db.notebooks .notebook(context.value.id) .topics.topic(context.value.topic) .delete(...items.map((i) => i.id)); store.refresh(); }, multiSelect: true, }, ]; function colorsToMenuItems() { return COLORS.map((label) => { const lowercase = label.toLowerCase(); return { key: lowercase, title: () => db.colors.alias(lowercase) || label, icon: Icon.Circle, iconColor: lowercase, checked: ({ note }) => { return note.color === lowercase; }, onClick: ({ note }) => { const { id } = note; store.setColor(id, lowercase); }, }; }); }