diff --git a/package.json b/package.json index bcbf27d..6b62f75 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", - "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --mac --publish never", + "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --mac --publish always", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .", @@ -115,8 +115,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.11.2", "react-textarea-autosize": "^8.5.3", - "react-virtualized-auto-sizer": "^1.0.20", - "react-window": "^1.8.10" + "react-virtuoso": "^4.6.2" }, "devDependencies": { "@adobe/css-tools": "^4.3.2", @@ -275,4 +274,4 @@ ], "logLevel": "quiet" } -} +} \ No newline at end of file diff --git a/release/app/package-lock.json b/release/app/package-lock.json index 7e45acc..d8ec4be 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "pile", - "version": "0.8.2", + "version": "0.9.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pile", - "version": "0.8.2", + "version": "0.9.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/release/app/package.json b/release/app/package.json index 9a06d12..e42a13e 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "pile", - "version": "0.8.2", + "version": "0.9.0", "description": "Pile: Everyday journal and thought companion.", "license": "MIT", "author": { diff --git a/src/main/utils/pileVectorIndex.js b/src/main/utils/pileVectorIndex.js index e2c686c..629ff05 100644 --- a/src/main/utils/pileVectorIndex.js +++ b/src/main/utils/pileVectorIndex.js @@ -4,6 +4,7 @@ const glob = require('glob'); const matter = require('gray-matter'); const keytar = require('keytar'); const pileIndex = require('./pileIndex'); +const { BrowserWindow } = require('electron'); const { TextNode, @@ -46,6 +47,13 @@ class PileVectorIndex { await this.initChatEngine(); } + async sendMessageToRenderer(channel = 'status', message) { + let win = BrowserWindow.getFocusedWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send(channel, message); + } + } + async setAPIKeyToEnv() { const apikey = await keytar.getPassword('pile', 'aikey'); if (!apikey) { @@ -95,7 +103,7 @@ class PileVectorIndex { ); // Build the vector store for existing entries. - console.log('🛠️ Building vector index'); + console.log('🛠️ Building fresh vector index'); this.rebuildVectorIndex(this.pilePath); } } @@ -267,6 +275,8 @@ class PileVectorIndex { const index = new Map(JSON.parse(data)); const documents = []; + let count = 1; + console.log('🟢 Rebuilding vector store...'); // it makes sense to compile each thread into one document before // injesting it here... the metadata can includes the relative paths of @@ -315,13 +325,36 @@ class PileVectorIndex { const replies = await Promise.all(childNodePromises); thread.relationships[NodeRelationship.CHILD] = replies; + this.addDocument(thread); + + this.sendMessageToRenderer('vector-index', { + type: 'indexing', + count: count, + total: index.size, + message: `Indexed entry ${count}/${index.size}`, + }); + console.log('✅ Successfully indexed file', relativeFilePath); } + count++; } catch (error) { + this.sendMessageToRenderer('vector-index', { + type: 'indexing', + count: count, + total: index.size, + message: `Failed to index an entry. Skipping ${count}/${index.size}`, + }); console.log('❌ Failed to embed and index:', relativeFilePath); } } + + this.sendMessageToRenderer('vector-index', { + type: 'done', + count: index.size, + total: index.size, + message: `Indexing complete`, + }); console.log('🟢 Finished building index'); } diff --git a/src/renderer/context/TimelineContext.js b/src/renderer/context/TimelineContext.js index b3df7eb..ab6a1f6 100644 --- a/src/renderer/context/TimelineContext.js +++ b/src/renderer/context/TimelineContext.js @@ -4,6 +4,7 @@ import { useContext, useEffect, useCallback, + useRef, } from 'react'; import { useLocation } from 'react-router-dom'; import debounce from 'renderer/utils/debounce'; @@ -11,13 +12,32 @@ import debounce from 'renderer/utils/debounce'; export const TimelineContext = createContext(); export const TimelineContextProvider = ({ children }) => { - const [closestDate, _setClosestDate] = useState(new Date()); + const [visibleIndex, _setVisibleIndex] = useState(0); + const [closestDate, setClosestDate] = useState(new Date()); + const virtualListRef = useRef(null); - const setClosestDate = debounce((val) => { - _setClosestDate(val); + const setVisibleIndex = debounce((index) => { + _setVisibleIndex(index); }, 15); - const timelineContextValue = { closestDate, setClosestDate }; + const scrollToIndex = useCallback((index = 0) => { + if (!virtualListRef.current) return; + if (index == -1) return; + virtualListRef.current.scrollToIndex({ + index, + align: 'end', + behavior: 'auto', + }); + }, []); + + const timelineContextValue = { + virtualListRef, + visibleIndex, + closestDate, + setClosestDate, + scrollToIndex, + setVisibleIndex, + }; return ( diff --git a/src/renderer/hooks/useIPCListener.js b/src/renderer/hooks/useIPCListener.js index b20065b..17f07e6 100644 --- a/src/renderer/hooks/useIPCListener.js +++ b/src/renderer/hooks/useIPCListener.js @@ -4,7 +4,7 @@ const useIPCListener = (channel, initialData) => { const [data, setData] = useState(initialData); useEffect(() => { - const handler = (event, newData) => { + const handler = (newData) => { setData(newData); }; diff --git a/src/renderer/pages/Pile/Editor/Attachments/index.jsx b/src/renderer/pages/Pile/Editor/Attachments/index.jsx index 06e93d6..0c75f33 100644 --- a/src/renderer/pages/Pile/Editor/Attachments/index.jsx +++ b/src/renderer/pages/Pile/Editor/Attachments/index.jsx @@ -4,11 +4,11 @@ import { DiscIcon, PhotoIcon, TrashIcon, TagIcon } from 'renderer/icons'; import { motion } from 'framer-motion'; import { usePilesContext } from 'renderer/context/PilesContext'; -export default function Attachments({ +const Attachments = ({ post, onRemoveAttachment = () => {}, editable = false, -}) { +}) => { const { getCurrentPilePath } = usePilesContext(); if (!post) return; @@ -42,4 +42,6 @@ export default function Attachments({ ); } }); -} +}; + +export default Attachments; diff --git a/src/renderer/pages/Pile/Editor/LinkPreviews/index.jsx b/src/renderer/pages/Pile/Editor/LinkPreviews/index.jsx index d721eb3..11dde03 100644 --- a/src/renderer/pages/Pile/Editor/LinkPreviews/index.jsx +++ b/src/renderer/pages/Pile/Editor/LinkPreviews/index.jsx @@ -1,5 +1,5 @@ import styles from './LinkPreviews.module.scss'; -import { useCallback, useState, useEffect } from 'react'; +import { useCallback, useState, useEffect, memo } from 'react'; import { DiscIcon, PhotoIcon, TrashIcon, TagIcon } from 'renderer/icons'; import { motion, AnimatePresence } from 'framer-motion'; import LinkPreview from './LinkPreview'; @@ -24,7 +24,7 @@ const extractLinks = (htmlString) => { return urls.filter((value, index, self) => self.indexOf(value) === index); }; -export default function LinkPreviews({ post, editable = false }) { +const LinkPreviews = memo(({ post, editable = false }) => { const getPreview = (url) => { return window.electron.ipc.invoke('get-link-preview', url); }; @@ -42,4 +42,5 @@ export default function LinkPreviews({ post, editable = false }) { {renderLinks()} ); -} +}); +export default LinkPreviews; diff --git a/src/renderer/pages/Pile/Editor/index.jsx b/src/renderer/pages/Pile/Editor/index.jsx index ba21246..61f9392 100644 --- a/src/renderer/pages/Pile/Editor/index.jsx +++ b/src/renderer/pages/Pile/Editor/index.jsx @@ -1,6 +1,6 @@ import './ProseMirror.scss'; import styles from './Editor.module.scss'; -import { useCallback, useState, useEffect, useRef } from 'react'; +import { useCallback, useState, useEffect, useRef, memo } from 'react'; import { Extension } from '@tiptap/core'; import { useEditor, EditorContent } from '@tiptap/react'; import Link from '@tiptap/extension-link'; @@ -22,348 +22,335 @@ import useThread from 'renderer/hooks/useThread'; import LinkPreviews from './LinkPreviews'; import { useToastsContext } from 'renderer/context/ToastsContext'; -export default function Editor({ - postPath = null, - editable = false, - parentPostPath = null, - isAI = false, - isReply = false, - closeReply = () => {}, - setEditable = () => {}, - reloadParentPost, -}) { - const { - post, - savePost, - addTag, - removeTag, - attachToPost, - detachFromPost, - setContent, - resetPost, - deletePost, - } = usePost(postPath, { isReply, parentPostPath, reloadParentPost, isAI }); - const { getThread } = useThread(); - const { ai, prompt } = useAIContext(); - const { addNotification, removeNotification } = useToastsContext(); - - const isNew = !postPath; - - const EnterSubmitExtension = Extension.create({ - name: 'EnterSubmitExtension', - addCommands() { - return { - triggerSubmit: - () => - ({ state, dispatch }) => { - // This will trigger a 'submit' event on the editor - const event = new CustomEvent('submit'); - document.dispatchEvent(event); +const Editor = memo( + ({ + postPath = null, + editable = false, + parentPostPath = null, + isAI = false, + isReply = false, + closeReply = () => {}, + setEditable = () => {}, + reloadParentPost, + }) => { + const { + post, + savePost, + addTag, + removeTag, + attachToPost, + detachFromPost, + setContent, + resetPost, + deletePost, + } = usePost(postPath, { isReply, parentPostPath, reloadParentPost, isAI }); + const { getThread } = useThread(); + const { ai, prompt } = useAIContext(); + const { addNotification, removeNotification } = useToastsContext(); + + const isNew = !postPath; + + const EnterSubmitExtension = Extension.create({ + name: 'EnterSubmitExtension', + addCommands() { + return { + triggerSubmit: + () => + ({ state, dispatch }) => { + // This will trigger a 'submit' event on the editor + const event = new CustomEvent('submit'); + document.dispatchEvent(event); + + return true; + }, + }; + }, + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + editor.commands.triggerSubmit(); return true; }, - }; - }, - - addKeyboardShortcuts() { - return { - Enter: ({ editor }) => { - editor.commands.triggerSubmit(); - return true; - }, - }; - }, - }); - - const editor = useEditor({ - extensions: [ - StarterKit, - Typography, - Link, - Placeholder.configure({ - placeholder: isAI ? 'AI is thinking...' : 'What are you thinking?', - }), - CharacterCount.configure({ - limit: 10000, - }), - EnterSubmitExtension, - ], - editorProps: { - handlePaste: function (view, event, slice) { - const items = Array.from(event.clipboardData?.items || []); - for (const item of items) { - if (item.type.indexOf('image') === 0) { - const file = item.getAsFile(); - const fileName = file.name; // Retrieve the filename - const fileExtension = fileName.split('.').pop(); // Extract the file extension - // Handle the image file here (e.g., upload, display, etc.) - const reader = new FileReader(); - reader.onload = () => { - const imageData = reader.result; - attachToPost(imageData, fileExtension); - }; - reader.readAsDataURL(file); - - return true; // handled + }; + }, + }); + + const editor = useEditor({ + extensions: [ + StarterKit, + Typography, + Link, + Placeholder.configure({ + placeholder: isAI ? 'AI is thinking...' : 'What are you thinking?', + }), + CharacterCount.configure({ + limit: 10000, + }), + EnterSubmitExtension, + ], + editorProps: { + handlePaste: function (view, event, slice) { + const items = Array.from(event.clipboardData?.items || []); + for (const item of items) { + if (item.type.indexOf('image') === 0) { + const file = item.getAsFile(); + const fileName = file.name; // Retrieve the filename + const fileExtension = fileName.split('.').pop(); // Extract the file extension + // Handle the image file here (e.g., upload, display, etc.) + const reader = new FileReader(); + reader.onload = () => { + const imageData = reader.result; + attachToPost(imageData, fileExtension); + }; + reader.readAsDataURL(file); + + return true; // handled + } } - } - return false; // not handled use default behaviour + return false; // not handled use default behaviour + }, }, - }, - autofocus: true, - editable: editable, - content: post?.content || '', - onUpdate: ({ editor }) => { - setContent(editor.getHTML()); - }, - }); - - const elRef = useRef(); - const [deleteStep, setDeleteStep] = useState(0); - const [isDragging, setIsDragging] = useState(false); - const [isAIResponding, setIsAiResponding] = useState(false); - const [prevDragPos, setPrevDragPos] = useState(0); - - const handleMouseDown = (e) => { - setIsDragging(true); - setPrevDragPos(e.clientX); - }; - - const handleMouseMove = (e) => { - if (isDragging && elRef.current) { - const delta = e.clientX - prevDragPos; - elRef.current.scrollLeft -= delta; + autofocus: true, + editable: editable, + content: post?.content || '', + onUpdate: ({ editor }) => { + setContent(editor.getHTML()); + }, + }); + + const elRef = useRef(); + const [deleteStep, setDeleteStep] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [isAIResponding, setIsAiResponding] = useState(false); + const [prevDragPos, setPrevDragPos] = useState(0); + + const handleMouseDown = (e) => { + setIsDragging(true); setPrevDragPos(e.clientX); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - }; - - useEffect(() => { - if (!editor) return; - generateAiResponse(); - }, [editor, isAI]); - - const handleSubmit = useCallback(async () => { - await savePost(); - if (isNew) { - resetPost(); - closeReply(); - return; - } - - closeReply(); - setEditable(false); - }, [editor, isNew, post]); - - // Listen for the 'submit' event and call handleSubmit when it's triggered - useEffect(() => { - const handleEvent = () => { - if (editor?.isFocused) { - handleSubmit(); - } }; - document.addEventListener('submit', handleEvent); + const handleMouseMove = (e) => { + if (isDragging && elRef.current) { + const delta = e.clientX - prevDragPos; + elRef.current.scrollLeft -= delta; + setPrevDragPos(e.clientX); + } + }; - return () => { - document.removeEventListener('submit', handleEvent); + const handleMouseUp = () => { + setIsDragging(false); }; - }, [handleSubmit, editor]); - - // This has to ensure that it only calls the AI generate function - // on entries added for the AI that are empty. - const generateAiResponse = useCallback(async () => { - if (!editor) return; - if (isAIResponding) return; - - const isEmpty = editor.state.doc.textContent.length === 0; - - // isAI makes sure AI responses are only generated for - // AI entries that are empty. - if (isAI && isEmpty) { - addNotification({ - id: 'reflecting', - type: 'thinking', - message: 'talking to AI', - dismissTime: 10000, - }); + + useEffect(() => { + if (!editor) return; + generateAiResponse(); + }, [editor, isAI]); + + const handleSubmit = useCallback(async () => { + await savePost(); + if (isNew) { + resetPost(); + closeReply(); + return; + } + + closeReply(); setEditable(false); - setIsAiResponding(true); - const thread = await getThread(parentPostPath); - let context = []; - context.push({ - role: 'system', - content: prompt, - }); - - // todo: if post content contains links then attach their summary - // as context as well - thread.forEach((post) => { - const message = { role: 'user', content: post.content }; - context.push(message); - }); - - context.push({ - role: 'system', - content: 'You can only respond in plaintext, do NOT use HTML.', - }); - - if (context.length === 0) return; - - const stream = await ai.chat.completions.create({ - model: 'gpt-4', - stream: true, - max_tokens: 200, - messages: context, - }); - - for await (const part of stream) { - const token = part.choices[0].delta.content; - editor.commands.insertContent(token); + }, [editor, isNew, post]); + + // Listen for the 'submit' event and call handleSubmit when it's triggered + useEffect(() => { + const handleEvent = () => { + if (editor?.isFocused) { + handleSubmit(); + } + }; + + document.addEventListener('submit', handleEvent); + + return () => { + document.removeEventListener('submit', handleEvent); + }; + }, [handleSubmit, editor]); + + // This has to ensure that it only calls the AI generate function + // on entries added for the AI that are empty. + const generateAiResponse = useCallback(async () => { + if (!editor) return; + if (isAIResponding) return; + + const isEmpty = editor.state.doc.textContent.length === 0; + + // isAI makes sure AI responses are only generated for + // AI entries that are empty. + if (isAI && isEmpty) { + addNotification({ + id: 'reflecting', + type: 'thinking', + message: 'talking to AI', + dismissTime: 10000, + }); + setEditable(false); + setIsAiResponding(true); + const thread = await getThread(parentPostPath); + let context = []; + context.push({ + role: 'system', + content: prompt, + }); + + // todo: if post content contains links then attach their summary + // as context as well + thread.forEach((post) => { + const message = { role: 'user', content: post.content }; + context.push(message); + }); + + context.push({ + role: 'system', + content: 'You can only respond in plaintext, do NOT use HTML.', + }); + + if (context.length === 0) return; + + const stream = await ai.chat.completions.create({ + model: 'gpt-4', + stream: true, + max_tokens: 200, + messages: context, + }); + + for await (const part of stream) { + const token = part.choices[0].delta.content; + editor.commands.insertContent(token); + } + removeNotification('reflecting'); + setIsAiResponding(false); } - removeNotification('reflecting'); - setIsAiResponding(false); - } - }, [editor, isAI]); - - useEffect(() => { - if (editor) { - if (!post) return; - if (post?.content != editor.getHTML()) { - editor.commands.setContent(post.content); + }, [editor, isAI]); + + useEffect(() => { + if (editor) { + if (!post) return; + if (post?.content != editor.getHTML()) { + editor.commands.setContent(post.content); + } } - } - }, [post, editor]); - - const triggerAttachment = () => attachToPost(); - - useEffect(() => { - if (editor) { - editor.setEditable(editable); - } - setDeleteStep(0); - }, [editable]); - - const handleOnDelete = useCallback(async () => { - if (deleteStep == 0) { - setDeleteStep(1); - return; - } - - await deletePost(); - }, [deleteStep]); - - const isBig = useCallback(() => { - return editor?.storage.characterCount.characters() < 280; - }, [editor]); - - const renderPostButton = () => { - if (isAI) return 'Save AI response'; - if (isReply) return 'Reply'; - if (isNew) return 'Post'; - - return 'Update'; - }; - - if (!post) return; - - return ( -
- {editable ? ( - - ) : ( -
-
attachToPost(); + + useEffect(() => { + if (editor) { + editor.setEditable(editable); + } + setDeleteStep(0); + }, [editable]); + + const handleOnDelete = useCallback(async () => { + if (deleteStep == 0) { + setDeleteStep(1); + return; + } + + await deletePost(); + }, [deleteStep]); + + const isBig = useCallback(() => { + return editor?.storage.characterCount.characters() < 280; + }, [editor]); + + const renderPostButton = () => { + if (isAI) return 'Save AI response'; + if (isReply) return 'Reply'; + if (isNew) return 'Post'; + + return 'Update'; + }; + + if (!post) return; + + return ( +
+ {editable ? ( + -
- )} - - - - +
+
+ )} + + + +
0 ? styles.open : '' + }`} >
0 ? styles.open : '' - }`} + className={`${styles.scroll} ${isNew && styles.new}`} + ref={elRef} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} > -
-
- - - -
+
+
- +
{editable && ( - -
-
- +
+
+ {isReply && ( + -
-
- {isReply && ( - - )} - - {!isNew && ( - - )} + )} + + {!isNew && ( -
+ )} +
-
+
)} -
-
- ); -} +
+ ); + } +); + +export default Editor; diff --git a/src/renderer/pages/Pile/Layout.tsx b/src/renderer/pages/Pile/Layout.tsx index 4d3f0dc..f789da6 100644 --- a/src/renderer/pages/Pile/Layout.tsx +++ b/src/renderer/pages/Pile/Layout.tsx @@ -3,19 +3,35 @@ import styles from './PileLayout.module.scss'; import { HomeIcon } from 'renderer/icons'; import Sidebar from './Sidebar/Timeline/index'; import { useIndexContext } from 'renderer/context/IndexContext'; -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useState } from 'react'; import { DateTime } from 'luxon'; import Settings from './Settings'; import HighlightsDialog from './Highlights'; import { usePilesContext } from 'renderer/context/PilesContext'; import Toasts from './Toasts'; import Reflections from './Reflections'; +import { useTimelineContext } from 'renderer/context/TimelineContext'; +import { AnimatePresence, motion } from 'framer-motion'; export default function PileLayout({ children }) { const { pileName } = useParams(); const { index, refreshIndex } = useIndexContext(); + const { visibleIndex, closestDate } = useTimelineContext(); const { currentTheme } = usePilesContext(); - const now = DateTime.now().toFormat('cccc, LLL dd, yyyy'); + + const [now, setNow] = useState(DateTime.now().toFormat('cccc, LLL dd, yyyy')); + + useEffect(() => { + try { + if (visibleIndex < 5) { + setNow(DateTime.now().toFormat('cccc, LLL dd, yyyy')); + } else { + setNow(DateTime.fromISO(closestDate).toFormat('cccc, LLL dd, yyyy')); + } + } catch (error) { + console.log('Failed to render header date'); + } + }, [visibleIndex, closestDate]); useEffect(() => { window.scrollTo(0, 0); @@ -51,7 +67,16 @@ export default function PileLayout({ children }) {
- {pileName} · {now} + {pileName} · + + {now} +
diff --git a/src/renderer/pages/Pile/NewPost/index.jsx b/src/renderer/pages/Pile/NewPost/index.jsx index cb10c2d..708bdc6 100644 --- a/src/renderer/pages/Pile/NewPost/index.jsx +++ b/src/renderer/pages/Pile/NewPost/index.jsx @@ -4,12 +4,12 @@ import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; import { DiscIcon, PaperclipIcon } from 'renderer/icons'; -import { useState } from 'react'; +import { useState, memo } from 'react'; import Editor from '../Editor'; import { usePilesContext } from 'renderer/context/PilesContext'; import usePost from 'renderer/hooks/usePost'; -export default function NewPost() { +const NewPost = memo(() => { const { currentPile, getCurrentPilePath } = usePilesContext(); return ( @@ -20,4 +20,6 @@ export default function NewPost() {
); -} +}); + +export default NewPost; diff --git a/src/renderer/pages/Pile/Posts/Post/Post.module.scss b/src/renderer/pages/Pile/Posts/Post/Post.module.scss index 934d6d5..41312df 100644 --- a/src/renderer/pages/Pile/Posts/Post/Post.module.scss +++ b/src/renderer/pages/Pile/Posts/Post/Post.module.scss @@ -1,7 +1,8 @@ .root { position: relative; - padding: 5px 0; + // padding: 5px 0; height: auto; + min-height: 72px; &.focused { background: var(--bg); @@ -174,8 +175,8 @@ .reply { .left { .connector { - margin-top: -14px; - height: 33px; + margin-top: -30px; + height: 48px; width: 2px; background: var(--border); transition: all ease-in-out 120ms; @@ -197,11 +198,10 @@ .actionsHolder { display: flex; - height: 25px; - min-height: 25px; - margin-top: -12px; + height: 40px; + min-height: 40px; + margin-top: -10px; position: relative; - z-index: 99; } .actions { diff --git a/src/renderer/pages/Pile/Posts/Post/index.jsx b/src/renderer/pages/Pile/Posts/Post/index.jsx index 8bdbb64..50b3771 100644 --- a/src/renderer/pages/Pile/Posts/Post/index.jsx +++ b/src/renderer/pages/Pile/Posts/Post/index.jsx @@ -26,7 +26,7 @@ import { useHighlightsContext } from 'renderer/context/HighlightsContext'; const Post = memo(({ postPath }) => { const { currentPile, getCurrentPilePath } = usePilesContext(); const { highlights } = useHighlightsContext(); - const { setClosestDate } = useTimelineContext(); + // const { setClosestDate } = useTimelineContext(); const { post, cycleColor, refreshPost, setHighlight } = usePost(postPath); const [hovering, setHover] = useState(false); const [replying, setReplying] = useState(false); @@ -53,34 +53,34 @@ const Post = memo(({ postPath }) => { const containerRef = useRef(); - useEffect(() => { - const container = containerRef.current; - - const handleIntersection = (entries) => { - const entry = entries[0]; - if (entry.isIntersecting) { - if (post.data.isReply) return; - setClosestDate(post.data.createdAt); - } - }; - - const options = { - root: null, - rootMargin: '-100px 0px 0px 0px', - threshold: 0, - }; - - const observer = new IntersectionObserver(handleIntersection, options); - if (container) { - observer.observe(container); - } - - return () => { - if (container) { - observer.unobserve(container); - } - }; - }, [containerRef, post]); + // useEffect(() => { + // const container = containerRef.current; + + // const handleIntersection = (entries) => { + // const entry = entries[0]; + // if (entry.isIntersecting) { + // if (post.data.isReply) return; + // setClosestDate(post.data.createdAt); + // } + // }; + + // const options = { + // root: null, + // rootMargin: '-100px 0px 0px 0px', + // threshold: 0, + // }; + + // const observer = new IntersectionObserver(handleIntersection, options); + // if (container) { + // observer.observe(container); + // } + + // return () => { + // if (container) { + // observer.unobserve(container); + // } + // }; + // }, [containerRef, post]); if (!post) return; if (post.content == '' && post.data.attachments.length == 0) return; diff --git a/src/renderer/pages/Pile/Posts/VirtualList.jsx b/src/renderer/pages/Pile/Posts/VirtualList.jsx index 3db75fd..1a43e02 100644 --- a/src/renderer/pages/Pile/Posts/VirtualList.jsx +++ b/src/renderer/pages/Pile/Posts/VirtualList.jsx @@ -14,69 +14,65 @@ import { } from 'react'; import { useIndexContext } from 'renderer/context/IndexContext'; import Post from './Post'; -import { VariableSizeList as List, areEqual } from 'react-window'; -import AutoSizer from 'react-virtualized-auto-sizer'; import NewPost from '../NewPost'; import { AnimatePresence, motion } from 'framer-motion'; import debounce from 'renderer/utils/debounce'; import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual'; import { useWindowResize } from 'renderer/hooks/useWindowResize'; +import { Virtuoso } from 'react-virtuoso'; +import { useTimelineContext } from 'renderer/context/TimelineContext'; -export default function VirtualList({ data }) { - const [windowWidth, windowHeight] = useWindowResize(); - const parentRef = useRef(null); - const virtualizer = useVirtualizer({ - count: data.length, - getScrollElement: () => parentRef.current, - estimateSize: (index) => data[index][1].height || 150, - getItemKey: (index) => data[index][0], - overscan: 50, - }); +const VirtualList = memo(({ data }) => { + const { virtualListRef, setVisibleIndex } = useTimelineContext(); + const [isScrolling, setIsScrolling] = useState(false); - const items = virtualizer.getVirtualItems(); + const handleRangeChanged = (range) => { + const middle = Math.floor((range.startIndex + range.endIndex) / 2); + setVisibleIndex(middle); + }; - return ( -
-
-
{ + const [postPath] = entry; + + if (index == 0) return ; + + return ( +
+ - {items.map(({ index, key, size }) => { - const [postPath, metadata] = data[index]; - console.log('size', size); - return ( -
- {index == 0 && } - {index > 0 && } -
- ); - })} -
+ +
-
+ ); + }, []); + + const getKey = useCallback((index) => data[index][0], [data]); + + const handleIsScrolling = (bool) => { + setIsScrolling(bool); + }; + + return ( + + + ); -} +}); + +export default VirtualList; diff --git a/src/renderer/pages/Pile/Posts/index.jsx b/src/renderer/pages/Pile/Posts/index.jsx index b1a3516..a707003 100644 --- a/src/renderer/pages/Pile/Posts/index.jsx +++ b/src/renderer/pages/Pile/Posts/index.jsx @@ -6,8 +6,6 @@ import Placeholder from '@tiptap/extension-placeholder'; import { useState, useCallback, useEffect, useMemo, useRef, memo } from 'react'; import { useIndexContext } from 'renderer/context/IndexContext'; import Post from './Post'; -import { VariableSizeList as List, areEqual } from 'react-window'; -import AutoSizer from 'react-virtualized-auto-sizer'; import NewPost from '../NewPost'; import { AnimatePresence, motion } from 'framer-motion'; import debounce from 'renderer/utils/debounce'; @@ -34,6 +32,24 @@ export default function Posts() { setData([['NewPost', { height: 150 }], ...Array.from(onlyParentEntries)]); }, [index]); + // When there are zero entries + if (index.size == 0) { + return ( +
+ +
+
+
Say something?
+
+ Pile is ideal for journaling in bursts– type down what you're + thinking right now, come back to it over time. +
+
+
+
+ ); + } + return (
diff --git a/src/renderer/pages/Pile/Reflections/Status/index.jsx b/src/renderer/pages/Pile/Reflections/Status/index.jsx index bc21a11..b3b4f8c 100644 --- a/src/renderer/pages/Pile/Reflections/Status/index.jsx +++ b/src/renderer/pages/Pile/Reflections/Status/index.jsx @@ -7,6 +7,7 @@ import { DiscIcon, DownloadIcon, FlameIcon, + InfoIcon, } from 'renderer/icons'; import { useEffect, useState } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; @@ -23,13 +24,18 @@ import Waiting from '../../Toasts/Toast/Loaders/Waiting'; export default function Status() { const statusFromMain = useIPCListener('vector-index', ''); const [setupRun, setSetupRun] = useState(false); - const [status, setStatus] = useState('Loading index...'); + const [status, setStatus] = useState('loading'); + const [message, setMessage] = useState({ + type: 'loading', + message: 'Loading index...', + }); const { initVectorIndex, rebuildVectorIndex, query, getVectorIndex } = useIndexContext(); useEffect(() => { if (statusFromMain) { - setStatus(statusFromMain); + setStatus(statusFromMain.type); + setMessage(statusFromMain); const timer = setTimeout(() => { setStatus(''); @@ -61,8 +67,14 @@ export default function Status() { const renderIcon = (status) => { switch (status) { - case 'Loading index...': + case 'loading': return ; + case 'querying': + return ; + case 'indexing': + return ; + case 'done': + return ; default: return ; } @@ -70,8 +82,10 @@ export default function Status() { return (
- {renderIcon()} -
{status || 'Search or Ask'}
+ {renderIcon(status)} +
+ {status ? message.message : 'Search or Ask'} +
); } diff --git a/src/renderer/pages/Pile/Sidebar/Timeline/index.jsx b/src/renderer/pages/Pile/Sidebar/Timeline/index.jsx index 58fd812..2f0e128 100644 --- a/src/renderer/pages/Pile/Sidebar/Timeline/index.jsx +++ b/src/renderer/pages/Pile/Sidebar/Timeline/index.jsx @@ -3,10 +3,19 @@ import styles from './Timeline.module.scss'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; -import { useEffect, useStatem, useRef } from 'react'; +import { + useEffect, + useStatem, + useRef, + useState, + memo, + useMemo, + useCallback, +} from 'react'; import { DateTime } from 'luxon'; import { useTimelineContext } from 'renderer/context/TimelineContext'; import { useIndexContext } from 'renderer/context/IndexContext'; +import { on } from 'events'; function isToday(date) { const today = new Date(); @@ -50,7 +59,7 @@ const renderCount = (count) => { ); }; -function DayComponent({ date }) { +const DayComponent = memo(({ date, scrollToDate }) => { const { index } = useIndexContext(); const dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; const dayName = dayNames[date.getDay()]; @@ -59,6 +68,9 @@ function DayComponent({ date }) { return (
{ + scrollToDate(date); + }} className={`${styles.day} ${isToday(date) && styles.today} ${ dayName == 'S' && styles.monday }`} @@ -69,9 +81,9 @@ function DayComponent({ date }) {
{dayNumber}
); -} +}); -function WeekComponent({ startDate, endDate }) { +const WeekComponent = memo(({ startDate, endDate, scrollToDate }) => { const weekOfMonth = Math.floor(startDate.getDate() / 7) + 1; const monthNames = [ 'January', @@ -95,7 +107,13 @@ function WeekComponent({ startDate, endDate }) { date <= endDate; date.setDate(date.getDate() + 1) ) { - days.push(); + days.push( + + ); } const weekOfMonthText = () => { @@ -126,12 +144,53 @@ function WeekComponent({ startDate, endDate }) {
); -} +}); -const Timeline = () => { +const Timeline = memo(() => { const scrollRef = useRef(null); const scrubRef = useRef(null); - const { closestDate } = useTimelineContext(); + const { index } = useIndexContext(); + const { visibleIndex, scrollToIndex, closestDate, setClosestDate } = + useTimelineContext(); + const [parentEntries, setParentEntries] = useState([]); + + useEffect(() => { + if (!index) return; + const onlyParentEntries = Array.from(index).filter( + ([key, metadata]) => !metadata.isReply + ); + setParentEntries(onlyParentEntries); + }, [index]); + + useEffect(() => { + if (!parentEntries || parentEntries.length == 0) return; + if (visibleIndex == 0) return; + const current = parentEntries[visibleIndex - 1][1]; + const createdAt = current.createdAt; + setClosestDate(createdAt); + }, [visibleIndex, parentEntries]); + + const scrollToDate = useCallback( + (targetDate) => { + try { + let closestIndex = -1; + let smallestDiff = Infinity; + + parentEntries.forEach((post, index) => { + let postDate = new Date(post[1].createdAt); + let diff = Math.abs(targetDate - postDate); + if (diff < smallestDiff) { + smallestDiff = diff; + closestIndex = index; + } + }); + scrollToIndex(closestIndex); + } catch (error) { + console.error('Failed to scroll to entry', error); + } + }, + [parentEntries] + ); const getWeeks = () => { let weeks = []; @@ -159,9 +218,17 @@ const Timeline = () => { return weeks; }; - let weeks = getWeeks().map((week, index) => ( - - )); + const createWeeks = () => + getWeeks().map((week, index) => ( + + )); + + let weeks = useMemo(createWeeks, [parentEntries.length]); useEffect(() => { if (!scrubRef.current) return; @@ -195,6 +262,6 @@ const Timeline = () => {
); -}; +}); export default Timeline; diff --git a/src/renderer/pages/Pile/Toasts/Toast/Loaders/Waiting/index.jsx b/src/renderer/pages/Pile/Toasts/Toast/Loaders/Waiting/index.jsx index dd5cc6e..ae24d73 100644 --- a/src/renderer/pages/Pile/Toasts/Toast/Loaders/Waiting/index.jsx +++ b/src/renderer/pages/Pile/Toasts/Toast/Loaders/Waiting/index.jsx @@ -9,7 +9,7 @@ export default function Waiting(props) { stroke="currentcolor" > - + diff --git a/src/renderer/pages/Pile/index.tsx b/src/renderer/pages/Pile/index.tsx index 48c8cc7..7c9e2f2 100644 --- a/src/renderer/pages/Pile/index.tsx +++ b/src/renderer/pages/Pile/index.tsx @@ -1,5 +1,4 @@ import PileLayout from './Layout'; -import NewPost from './NewPost'; import Posts from './Posts'; export default function Pile() { diff --git a/yarn.lock b/yarn.lock index 4967c72..ad6ba9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1022,13 +1022,6 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime@^7.0.0": - version "7.23.5" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.5.tgz#11edb98f8aeec529b82b211028177679144242db" - integrity sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w== - dependencies: - regenerator-runtime "^0.14.0" - "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" @@ -8170,11 +8163,6 @@ memfs@^3.4.3: dependencies: fs-monkey "^1.0.4" -"memoize-one@>=3.1.1 <6": - version "5.2.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" - integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== - memory-fs@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" @@ -9750,18 +9738,10 @@ react-textarea-autosize@^8.5.3: use-composed-ref "^1.3.0" use-latest "^1.2.1" -react-virtualized-auto-sizer@^1.0.20: - version "1.0.20" - resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.20.tgz#d9a907253a7c221c52fa57dc775a6ef40c182645" - integrity sha512-OdIyHwj4S4wyhbKHOKM1wLSj/UDXm839Z3Cvfg2a9j+He6yDa6i5p0qQvEiCnyQlGO/HyfSnigQwuxvYalaAXA== - -react-window@^1.8.10: - version "1.8.10" - resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" - integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg== - dependencies: - "@babel/runtime" "^7.0.0" - memoize-one ">=3.1.1 <6" +react-virtuoso@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.6.2.tgz#74b59ebe3260e1f73e92340ffec84a6853285a12" + integrity sha512-vvlqvzPif+MvBrJ09+hJJrVY0xJK9yran+A+/1iwY78k0YCVKsyoNPqoLxOxzYPggspNBNXqUXEcvckN29OxyQ== react@^18.2.0: version "18.2.0"