From 184e56264c595fba13b15d9a215dfe4868df6b4b Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 22 Jan 2025 21:17:26 -0500 Subject: [PATCH] feat: Add reading time on pinned documents --- app/components/DocumentCard.tsx | 33 +++++++++++++++++++-- app/hooks/useTextStats.ts | 33 +++++++++++++++++++++ app/scenes/Document/components/Insights.tsx | 28 +---------------- shared/i18n/locales/en_US/translation.json | 1 + 4 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 app/hooks/useTextStats.ts diff --git a/app/components/DocumentCard.tsx b/app/components/DocumentCard.tsx index 275912eb9a..b485f5628a 100644 --- a/app/components/DocumentCard.tsx +++ b/app/components/DocumentCard.tsx @@ -1,8 +1,9 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { subDays } from "date-fns"; import { m } from "framer-motion"; import { observer } from "mobx-react"; -import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons"; +import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; @@ -18,6 +19,7 @@ import Flex from "~/components/Flex"; import NudeButton from "~/components/NudeButton"; import Time from "~/components/Time"; import useStores from "~/hooks/useStores"; +import { useTextStats } from "~/hooks/useTextStats"; import CollectionIcon from "./Icons/CollectionIcon"; import Text from "./Text"; import Tooltip from "./Tooltip"; @@ -70,6 +72,10 @@ function DocumentCard(props: Props) { [pin] ); + // If the document was updated within the last 7 days, show a timestamp instead of reading time + const isRecentlyUpdated = + new Date(document.updatedAt) > subDays(new Date(), 7); + return ( - - @@ -164,6 +176,21 @@ function DocumentCard(props: Props) { ); } +const ReadingTime = ({ document }: { document: Document }) => { + const { t } = useTranslation(); + const markdown = React.useMemo(() => document.toMarkdown(), [document]); + const stats = useTextStats(markdown); + + return ( + <> + + {t(`{{ minutes }}m read`, { + minutes: stats.total.readingTime, + })} + + ); +}; + const DocumentSquircle = ({ icon, color, diff --git a/app/hooks/useTextStats.ts b/app/hooks/useTextStats.ts new file mode 100644 index 0000000000..b6d49bcca2 --- /dev/null +++ b/app/hooks/useTextStats.ts @@ -0,0 +1,33 @@ +import emojiRegex from "emoji-regex"; + +/** + * Hook to calculate text statistics + * @param text The string to calculate statistics for + * @param selectedText A substring of the text to calculate statistics for + * @returns An object containing total and selected statistics + */ +export function useTextStats(text: string, selectedText: string = "") { + const numTotalWords = countWords(text); + const regex = emojiRegex(); + const matches = Array.from(text.matchAll(regex)); + + return { + total: { + words: numTotalWords, + characters: text.length, + emoji: matches.length ?? 0, + readingTime: Math.max(1, Math.floor(numTotalWords / 200)), + }, + selected: { + words: countWords(selectedText), + characters: selectedText.length, + }, + }; +} + +function countWords(text: string): number { + const t = text.trim(); + + // Hyphenated words are counted as two words + return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0; +} diff --git a/app/scenes/Document/components/Insights.tsx b/app/scenes/Document/components/Insights.tsx index 06130cd785..1ecad36799 100644 --- a/app/scenes/Document/components/Insights.tsx +++ b/app/scenes/Document/components/Insights.tsx @@ -1,4 +1,3 @@ -import emojiRegex from "emoji-regex"; import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -20,6 +19,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useTextSelection from "~/hooks/useTextSelection"; +import { useTextStats } from "~/hooks/useTextStats"; import InsightsMenu from "~/menus/InsightsMenu"; import { documentPath } from "~/utils/routeHelpers"; import Sidebar from "./SidebarLayout"; @@ -213,32 +213,6 @@ function Insights() { ); } -function useTextStats(text: string, selectedText: string) { - const numTotalWords = countWords(text); - const regex = emojiRegex(); - const matches = Array.from(text.matchAll(regex)); - - return { - total: { - words: numTotalWords, - characters: text.length, - emoji: matches.length ?? 0, - readingTime: Math.max(1, Math.floor(numTotalWords / 200)), - }, - selected: { - words: countWords(selectedText), - characters: selectedText.length, - }, - }; -} - -function countWords(text: string): number { - const t = text.trim(); - - // Hyphenated words are counted as two words - return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0; -} - const ListSpacing = styled("div")` margin-top: -0.5em; margin-bottom: 0.5em; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 3a7706a378..7713486f4c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -197,6 +197,7 @@ "Install now": "Install now", "Deleted Collection": "Deleted Collection", "Unpin": "Unpin", + "{{ minutes }}m read": "{{ minutes }}m read", "Select a location to copy": "Select a location to copy", "Document copied": "Document copied", "Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",