From 5d71398ea6402dd25eae1107f94fc57a1d46fe60 Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Sat, 22 Jul 2023 21:43:09 +0530 Subject: [PATCH] Preview mentions (#5571) Co-authored-by: Tom Moor --- app/components/Editor.tsx | 1 - app/components/HoverPreview.tsx | 241 --------------- app/components/HoverPreview/Components.ts | 25 ++ app/components/HoverPreview/HoverPreview.tsx | 282 ++++++++++++++++++ .../HoverPreview/HoverPreviewDocument.tsx | 37 +++ .../HoverPreview/HoverPreviewMention.tsx | 41 +++ app/components/HoverPreview/index.ts | 3 + app/components/HoverPreviewDocument.tsx | 64 ---- app/components/LocaleTime.tsx | 2 +- .../Document/components/SharePopover.tsx | 2 +- app/utils/date.ts | 2 +- app/utils/i18n.ts | 51 ---- server/presenters/unfurls/common.ts | 103 +++++++ server/presenters/unfurls/document.ts | 21 ++ server/presenters/unfurls/index.ts | 4 + server/presenters/unfurls/mention.ts | 23 ++ server/routes/api/index.ts | 2 + server/routes/api/urls/index.ts | 1 + server/routes/api/urls/schema.ts | 36 +++ server/routes/api/urls/urls.test.ts | 140 +++++++++ server/routes/api/urls/urls.ts | 64 ++++ server/validation.ts | 23 ++ shared/editor/nodes/Mention.ts | 29 +- shared/i18n/locales/en_US/translation.json | 9 + shared/types.ts | 14 + shared/utils/date.ts | 52 ++++ shared/utils/parseMentionUrl.ts | 12 + 27 files changed, 923 insertions(+), 361 deletions(-) delete mode 100644 app/components/HoverPreview.tsx create mode 100644 app/components/HoverPreview/Components.ts create mode 100644 app/components/HoverPreview/HoverPreview.tsx create mode 100644 app/components/HoverPreview/HoverPreviewDocument.tsx create mode 100644 app/components/HoverPreview/HoverPreviewMention.tsx create mode 100644 app/components/HoverPreview/index.ts delete mode 100644 app/components/HoverPreviewDocument.tsx create mode 100644 server/presenters/unfurls/common.ts create mode 100644 server/presenters/unfurls/document.ts create mode 100644 server/presenters/unfurls/index.ts create mode 100644 server/presenters/unfurls/mention.ts create mode 100644 server/routes/api/urls/index.ts create mode 100644 server/routes/api/urls/schema.ts create mode 100644 server/routes/api/urls/urls.test.ts create mode 100644 server/routes/api/urls/urls.ts create mode 100644 shared/utils/parseMentionUrl.ts diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 539f34a41..a674f0367 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -349,7 +349,6 @@ function Editor(props: Props, ref: React.RefObject | null) { )} {activeLinkElement && !shareId && ( diff --git a/app/components/HoverPreview.tsx b/app/components/HoverPreview.tsx deleted file mode 100644 index ca2a7dc6f..000000000 --- a/app/components/HoverPreview.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { transparentize } from "polished"; -import * as React from "react"; -import { Portal } from "react-portal"; -import styled from "styled-components"; -import { depths, s } from "@shared/styles"; -import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; -import { isExternalUrl } from "@shared/utils/urls"; -import HoverPreviewDocument from "~/components/HoverPreviewDocument"; -import useMobile from "~/hooks/useMobile"; -import { fadeAndSlideDown } from "~/styles/animations"; - -const DELAY_OPEN = 300; -const DELAY_CLOSE = 300; - -type Props = { - /* The document associated with the editor, if any */ - id?: string; - /* The HTML element that is being hovered over */ - element: HTMLAnchorElement; - /* A callback on close of the hover preview */ - onClose: () => void; -}; - -function HoverPreviewInternal({ element, id, onClose }: Props) { - const slug = parseDocumentSlug(element.href); - const [isVisible, setVisible] = React.useState(false); - const timerClose = React.useRef>(); - const timerOpen = React.useRef>(); - const cardRef = React.useRef(null); - - const startCloseTimer = () => { - stopOpenTimer(); - timerClose.current = setTimeout(() => { - if (isVisible) { - setVisible(false); - } - onClose(); - }, DELAY_CLOSE); - }; - - const stopCloseTimer = () => { - if (timerClose.current) { - clearTimeout(timerClose.current); - } - }; - - const startOpenTimer = () => { - timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN); - }; - - const stopOpenTimer = () => { - if (timerOpen.current) { - clearTimeout(timerOpen.current); - } - }; - - React.useEffect(() => { - startOpenTimer(); - - if (cardRef.current) { - cardRef.current.addEventListener("mouseenter", stopCloseTimer); - } - - if (cardRef.current) { - cardRef.current.addEventListener("mouseleave", startCloseTimer); - } - - element.addEventListener("mouseout", startCloseTimer); - element.addEventListener("mouseover", stopCloseTimer); - element.addEventListener("mouseover", startOpenTimer); - return () => { - element.removeEventListener("mouseout", startCloseTimer); - element.removeEventListener("mouseover", stopCloseTimer); - element.removeEventListener("mouseover", startOpenTimer); - - if (cardRef.current) { - cardRef.current.removeEventListener("mouseenter", stopCloseTimer); - } - - if (cardRef.current) { - cardRef.current.removeEventListener("mouseleave", startCloseTimer); - } - - if (timerClose.current) { - clearTimeout(timerClose.current); - } - }; - }, [element, slug]); - - const anchorBounds = element.getBoundingClientRect(); - const cardBounds = cardRef.current?.getBoundingClientRect(); - const left = cardBounds - ? Math.min(anchorBounds.left, window.innerWidth - 16 - 350) - : anchorBounds.left; - const leftOffset = anchorBounds.left - left; - - return ( - - -
- - {(content: React.ReactNode) => - isVisible ? ( - - - - {content} - - - - ) : null - } - -
-
-
- ); -} - -function HoverPreview({ element, ...rest }: Props) { - const isMobile = useMobile(); - if (isMobile) { - return null; - } - - // previews only work for internal doc links for now - if (isExternalUrl(element.href)) { - return null; - } - - return ; -} - -const Animate = styled.div` - animation: ${fadeAndSlideDown} 150ms ease; - - @media print { - display: none; - } -`; - -// fills the gap between the card and pointer to avoid a dead zone -const Margin = styled.div` - position: absolute; - top: -11px; - left: 0; - right: 0; - height: 11px; -`; - -const CardContent = styled.div` - overflow: hidden; - max-height: 20em; - user-select: none; -`; - -// &:after — gradient mask for overflow text -const Card = styled.div` - backdrop-filter: blur(10px); - background: ${s("background")}; - border-radius: 4px; - box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3), - 0 0 1px 1px rgba(0, 0, 0, 0.05); - padding: 16px; - width: 350px; - font-size: 0.9em; - position: relative; - - .placeholder, - .heading-anchor { - display: none; - } - - &:after { - content: ""; - display: block; - position: absolute; - pointer-events: none; - background: linear-gradient( - 90deg, - ${(props) => transparentize(1, props.theme.background)} 0%, - ${(props) => transparentize(1, props.theme.background)} 75%, - ${s("background")} 90% - ); - bottom: 0; - left: 0; - right: 0; - height: 1.7em; - border-bottom: 16px solid ${s("background")}; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - } -`; - -const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>` - margin-top: 10px; - position: ${({ fixed }) => (fixed ? "fixed" : "absolute")}; - z-index: ${depths.hoverPreview}; - display: flex; - max-height: 75%; - - ${({ top }) => (top !== undefined ? `top: ${top}px` : "")}; - ${({ left }) => (left !== undefined ? `left: ${left}px` : "")}; -`; - -const Pointer = styled.div<{ offset: number }>` - top: -22px; - left: ${(props) => props.offset}px; - width: 22px; - height: 22px; - position: absolute; - transform: translateX(-50%); - pointer-events: none; - - &:before, - &:after { - content: ""; - display: inline-block; - position: absolute; - bottom: 0; - right: 0; - } - - &:before { - border: 8px solid transparent; - border-bottom-color: ${(props) => - props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"}; - right: -1px; - } - - &:after { - border: 7px solid transparent; - border-bottom-color: ${s("background")}; - } -`; - -export default HoverPreview; diff --git a/app/components/HoverPreview/Components.ts b/app/components/HoverPreview/Components.ts new file mode 100644 index 000000000..5f648c837 --- /dev/null +++ b/app/components/HoverPreview/Components.ts @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { s } from "@shared/styles"; +import Text from "~/components/Text"; + +export const Preview = styled(Link)` + cursor: var(--pointer); + margin-bottom: 0; + ${(props) => (!props.to ? "pointer-events: none;" : "")} +`; + +export const Title = styled.h2` + font-size: 1.25em; + margin: 2px 0 0 0; + color: ${s("text")}; +`; + +export const Description = styled(Text)` + margin-bottom: 0; + padding-top: 2px; +`; + +export const Summary = styled.div` + margin-top: 8px; +`; diff --git a/app/components/HoverPreview/HoverPreview.tsx b/app/components/HoverPreview/HoverPreview.tsx new file mode 100644 index 000000000..f4f129f7d --- /dev/null +++ b/app/components/HoverPreview/HoverPreview.tsx @@ -0,0 +1,282 @@ +import { transparentize } from "polished"; +import * as React from "react"; +import { Portal } from "react-portal"; +import styled from "styled-components"; +import { depths, s } from "@shared/styles"; +import { UnfurlType } from "@shared/types"; +import LoadingIndicator from "~/components/LoadingIndicator"; +import useMobile from "~/hooks/useMobile"; +import useRequest from "~/hooks/useRequest"; +import useStores from "~/hooks/useStores"; +import { fadeAndSlideDown } from "~/styles/animations"; +import { client } from "~/utils/ApiClient"; +import HoverPreviewDocument from "./HoverPreviewDocument"; +import HoverPreviewMention from "./HoverPreviewMention"; + +const DELAY_OPEN = 300; +const DELAY_CLOSE = 300; +const CARD_PADDING = 16; +const CARD_MAX_WIDTH = 375; + +type Props = { + /* The HTML element that is being hovered over */ + element: HTMLAnchorElement; + /* A callback on close of the hover preview */ + onClose: () => void; +}; + +function HoverPreviewInternal({ element, onClose }: Props) { + const url = element.href || element.dataset.url; + const [isVisible, setVisible] = React.useState(false); + const timerClose = React.useRef>(); + const timerOpen = React.useRef>(); + const cardRef = React.useRef(null); + const stores = useStores(); + const { data, request, loading } = useRequest( + React.useCallback( + () => + client.post("/urls.unfurl", { + url, + documentId: stores.ui.activeDocumentId, + }), + [url, stores.ui.activeDocumentId] + ) + ); + + React.useEffect(() => { + if (url) { + void request(); + } + }, [url, request]); + + const startCloseTimer = React.useCallback(() => { + stopOpenTimer(); + timerClose.current = setTimeout(() => { + if (isVisible) { + setVisible(false); + } + onClose(); + }, DELAY_CLOSE); + }, [isVisible, onClose]); + + const stopCloseTimer = () => { + if (timerClose.current) { + clearTimeout(timerClose.current); + } + }; + + const startOpenTimer = () => { + timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN); + }; + + const stopOpenTimer = () => { + if (timerOpen.current) { + clearTimeout(timerOpen.current); + } + }; + + React.useEffect(() => { + const card = cardRef.current; + + if (data && !loading) { + startOpenTimer(); + + if (card) { + card.addEventListener("mouseenter", stopCloseTimer); + card.addEventListener("mouseleave", startCloseTimer); + } + + element.addEventListener("mouseout", startCloseTimer); + element.addEventListener("mouseover", stopCloseTimer); + element.addEventListener("mouseover", startOpenTimer); + } + + return () => { + element.removeEventListener("mouseout", startCloseTimer); + element.removeEventListener("mouseover", stopCloseTimer); + element.removeEventListener("mouseover", startOpenTimer); + + if (card) { + card.removeEventListener("mouseenter", stopCloseTimer); + card.removeEventListener("mouseleave", startCloseTimer); + } + + stopCloseTimer(); + }; + }, [element, startCloseTimer, data, loading]); + + const elemBounds = element.getBoundingClientRect(); + const cardBounds = cardRef.current?.getBoundingClientRect(); + const left = cardBounds + ? Math.min( + elemBounds.left, + window.innerWidth - CARD_PADDING - CARD_MAX_WIDTH + ) + : elemBounds.left; + const leftOffset = elemBounds.left - left; + + if (loading) { + return ; + } + + if (!data) { + return null; + } + + return ( + + +
+ {isVisible ? ( + + + + + {data.type === UnfurlType.Mention ? ( + + ) : data.type === UnfurlType.Document ? ( + + ) : null} + + + + + ) : null} +
+
+
+ ); +} + +function HoverPreview({ element, ...rest }: Props) { + const isMobile = useMobile(); + if (isMobile) { + return null; + } + + return ; +} + +const Animate = styled.div` + animation: ${fadeAndSlideDown} 150ms ease; + + @media print { + display: none; + } +`; + +// fills the gap between the card and pointer to avoid a dead zone +const Margin = styled.div` + position: absolute; + top: -11px; + left: 0; + right: 0; + height: 11px; +`; + +const CardContent = styled.div` + overflow: hidden; + max-height: 20em; + user-select: none; +`; + +// &:after — gradient mask for overflow text +const Card = styled.div<{ fadeOut?: boolean }>` + backdrop-filter: blur(10px); + background: ${(props) => props.theme.menuBackground}; + border-radius: 4px; + box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3), + 0 0 1px 1px rgba(0, 0, 0, 0.05); + padding: ${CARD_PADDING}px; + min-width: 350px; + max-width: ${CARD_MAX_WIDTH}px; + font-size: 0.9em; + position: relative; + + .placeholder, + .heading-anchor { + display: none; + } + + ${(props) => + props.fadeOut !== false + ? `&:after { + content: ""; + display: block; + position: absolute; + pointer-events: none; + background: linear-gradient( + 90deg, + ${transparentize(1, props.theme.menuBackground)} 0%, + ${transparentize(1, props.theme.menuBackground)} 75%, + ${props.theme.menuBackground} 90% + ); + bottom: 0; + left: 0; + right: 0; + height: 1.7em; + border-bottom: 16px solid ${props.theme.menuBackground}; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + }` + : ""} +`; + +const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>` + margin-top: 10px; + position: ${({ fixed }) => (fixed ? "fixed" : "absolute")}; + z-index: ${depths.hoverPreview}; + display: flex; + max-height: 75%; + + ${({ top }) => (top !== undefined ? `top: ${top}px` : "")}; + ${({ left }) => (left !== undefined ? `left: ${left}px` : "")}; +`; + +const Pointer = styled.div<{ offset: number }>` + top: -22px; + left: ${(props) => props.offset}px; + width: 22px; + height: 22px; + position: absolute; + transform: translateX(-50%); + pointer-events: none; + + &:before, + &:after { + content: ""; + display: inline-block; + position: absolute; + bottom: 0; + right: 0; + } + + &:before { + border: 8px solid transparent; + border-bottom-color: ${(props) => + props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"}; + right: -1px; + } + + &:after { + border: 7px solid transparent; + border-bottom-color: ${s("background")}; + } +`; + +export default HoverPreview; diff --git a/app/components/HoverPreview/HoverPreviewDocument.tsx b/app/components/HoverPreview/HoverPreviewDocument.tsx new file mode 100644 index 000000000..a2e7ecc8b --- /dev/null +++ b/app/components/HoverPreview/HoverPreviewDocument.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import Editor from "~/components/Editor"; +import Flex from "~/components/Flex"; +import { Preview, Title, Description, Summary } from "./Components"; + +type Props = { + /** Document id associated with the editor, if any */ + id?: string; + /** Document url */ + url: string; + /** Title for the preview card */ + title: string; + /** Description about recent activity on document */ + description: string; + /** Summary of document content */ + summary: string; +}; + +function HoverPreviewDocument({ id, url, title, description, summary }: Props) { + return ( + + + {title} + + {description} + + + }> + + + + + + ); +} + +export default HoverPreviewDocument; diff --git a/app/components/HoverPreview/HoverPreviewMention.tsx b/app/components/HoverPreview/HoverPreviewMention.tsx new file mode 100644 index 000000000..b21cf19de --- /dev/null +++ b/app/components/HoverPreview/HoverPreviewMention.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import Avatar from "~/components/Avatar"; +import { AvatarSize } from "~/components/Avatar/Avatar"; +import Flex from "~/components/Flex"; +import { Preview, Title, Description } from "./Components"; + +type Props = { + /** Resource url, avatar url in case of user mention */ + url: string; + /** Title for the preview card*/ + title: string; + /** Description about mentioned user's recent activity */ + description: string; + /** Used for avatar's background color in absence of avatar url */ + color: string; +}; + +function HoverPreviewMention({ url, title, description, color }: Props) { + return ( + + + + + {title} + + {description} + + + + + ); +} + +export default HoverPreviewMention; diff --git a/app/components/HoverPreview/index.ts b/app/components/HoverPreview/index.ts new file mode 100644 index 000000000..6e739ff89 --- /dev/null +++ b/app/components/HoverPreview/index.ts @@ -0,0 +1,3 @@ +import HoverPreview from "./HoverPreview"; + +export default HoverPreview; diff --git a/app/components/HoverPreviewDocument.tsx b/app/components/HoverPreviewDocument.tsx deleted file mode 100644 index f85aad991..000000000 --- a/app/components/HoverPreviewDocument.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { observer } from "mobx-react"; -import * as React from "react"; -import { Link } from "react-router-dom"; -import styled from "styled-components"; -import { s } from "@shared/styles"; -import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; -import DocumentMeta from "~/components/DocumentMeta"; -import Editor from "~/components/Editor"; -import useStores from "~/hooks/useStores"; - -type Props = { - /* The document associated with the editor, if any */ - id?: string; - /* The URL we want a preview for */ - url: string; - children: (content: React.ReactNode) => React.ReactNode; -}; - -function HoverPreviewDocument({ url, id, children }: Props) { - const { documents } = useStores(); - const slug = parseDocumentSlug(url); - - React.useEffect(() => { - if (slug) { - void documents.prefetchDocument(slug); - } - }, [documents, slug]); - - const document = slug ? documents.getByUrl(slug) : undefined; - if (!document || document.id === id) { - return null; - } - - return ( - <> - {children( - - {document.titleWithDefault} - - - }> - - - - )} - - ); -} - -const Content = styled(Link)` - cursor: var(--pointer); -`; - -const Heading = styled.h2` - margin: 0 0 0.75em; - color: ${s("text")}; -`; - -export default observer(HoverPreviewDocument); diff --git a/app/components/LocaleTime.tsx b/app/components/LocaleTime.tsx index 53a38bbe7..00e8d921a 100644 --- a/app/components/LocaleTime.tsx +++ b/app/components/LocaleTime.tsx @@ -1,8 +1,8 @@ import { format as formatDate, formatDistanceToNow } from "date-fns"; import * as React from "react"; +import { dateLocale, locales } from "@shared/utils/date"; import Tooltip from "~/components/Tooltip"; import useUserLocale from "~/hooks/useUserLocale"; -import { dateLocale, locales } from "~/utils/i18n"; let callbacks: (() => void)[] = []; diff --git a/app/scenes/Document/components/SharePopover.tsx b/app/scenes/Document/components/SharePopover.tsx index 5fb905837..6e3f6ca55 100644 --- a/app/scenes/Document/components/SharePopover.tsx +++ b/app/scenes/Document/components/SharePopover.tsx @@ -8,6 +8,7 @@ import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import styled from "styled-components"; import { s } from "@shared/styles"; +import { dateLocale } from "@shared/utils/date"; import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; import Document from "~/models/Document"; import Share from "~/models/Share"; @@ -24,7 +25,6 @@ import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import useUserLocale from "~/hooks/useUserLocale"; -import { dateLocale } from "~/utils/i18n"; type Props = { document: Document; diff --git a/app/utils/date.ts b/app/utils/date.ts index cc4d48ed6..dd1a01023 100644 --- a/app/utils/date.ts +++ b/app/utils/date.ts @@ -13,9 +13,9 @@ import { getCurrentDateTimeAsString, getCurrentTimeAsString, unicodeCLDRtoBCP47, + dateLocale, } from "@shared/utils/date"; import User from "~/models/User"; -import { dateLocale } from "~/utils/i18n"; export function dateToHeading( dateTime: string, diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts index 50da4e401..f9b984f1d 100644 --- a/app/utils/i18n.ts +++ b/app/utils/i18n.ts @@ -1,22 +1,3 @@ -import { - de, - enUS, - es, - faIR, - fr, - it, - ja, - ko, - nl, - ptBR, - pt, - pl, - ru, - tr, - vi, - zhCN, - zhTW, -} from "date-fns/locale"; import i18n from "i18next"; import backend from "i18next-http-backend"; import { initReactI18next } from "react-i18next"; @@ -24,36 +5,6 @@ import { languages } from "@shared/i18n"; import { unicodeCLDRtoBCP47, unicodeBCP47toCLDR } from "@shared/utils/date"; import Logger from "./Logger"; -const locales = { - de_DE: de, - en_US: enUS, - es_ES: es, - fa_IR: faIR, - fr_FR: fr, - it_IT: it, - ja_JP: ja, - ko_KR: ko, - nl_NL: nl, - pt_BR: ptBR, - pt_PT: pt, - pl_PL: pl, - ru_RU: ru, - tr_TR: tr, - vi_VN: vi, - zh_CN: zhCN, - zh_TW: zhTW, -}; - -/** - * Returns the date-fns locale object for the given user language preference. - * - * @param language The user language - * @returns The date-fns locale. - */ -export function dateLocale(language: string | null | undefined) { - return language ? locales[language] : undefined; -} - /** * Initializes i18n library, loading all available translations from the * API backend. @@ -94,5 +45,3 @@ export function initI18n(defaultLanguage = "en_US") { return i18n; } - -export { locales }; diff --git a/server/presenters/unfurls/common.ts b/server/presenters/unfurls/common.ts new file mode 100644 index 000000000..78e4e2634 --- /dev/null +++ b/server/presenters/unfurls/common.ts @@ -0,0 +1,103 @@ +import { differenceInMinutes, formatDistanceToNowStrict } from "date-fns"; +import { t } from "i18next"; +import { head, orderBy } from "lodash"; +import { dateLocale } from "@shared/utils/date"; +import { Document, User } from "@server/models"; +import { opts } from "@server/utils/i18n"; + +export const presentLastOnlineInfoFor = (user: User) => { + const locale = dateLocale(user.language); + + let info: string; + if (!user.lastActiveAt) { + info = t("Never logged in", { ...opts(user) }); + } else if (differenceInMinutes(new Date(), user.lastActiveAt) < 5) { + info = t("Online now", { ...opts(user) }); + } else { + info = t("Online {{ timeAgo }}", { + timeAgo: formatDistanceToNowStrict(user.lastActiveAt, { + addSuffix: true, + locale, + }), + ...opts(user), + }); + } + + return info; +}; + +export const presentLastViewedInfoFor = (user: User, document: Document) => { + const lastView = head(orderBy(document.views, ["updatedAt"], ["desc"])); + const lastViewedAt = lastView ? lastView.updatedAt : undefined; + const locale = dateLocale(user.language); + + let info: string; + if (!lastViewedAt) { + info = t("Never viewed", { ...opts(user) }); + } else if (differenceInMinutes(new Date(), lastViewedAt) < 5) { + info = t("Viewed just now", { ...opts(user) }); + } else { + info = t("Viewed {{ timeAgo }}", { + timeAgo: formatDistanceToNowStrict(lastViewedAt, { + addSuffix: true, + locale, + }), + ...opts(user), + }); + } + + return info; +}; + +export const presentLastActivityInfoFor = ( + document: Document, + viewer: User +) => { + const locale = dateLocale(viewer.language); + const wasUpdated = document.createdAt !== document.updatedAt; + + let info: string; + if (wasUpdated) { + const lastUpdatedByViewer = document.updatedBy.id === viewer.id; + if (lastUpdatedByViewer) { + info = t("You updated {{ timeAgo }}", { + timeAgo: formatDistanceToNowStrict(document.updatedAt, { + addSuffix: true, + locale, + }), + ...opts(viewer), + }); + } else { + info = t("{{ user }} updated {{ timeAgo }}", { + user: document.updatedBy.name, + timeAgo: formatDistanceToNowStrict(document.updatedAt, { + addSuffix: true, + locale, + }), + ...opts(viewer), + }); + } + } else { + const lastCreatedByViewer = document.createdById === viewer.id; + if (lastCreatedByViewer) { + info = t("You created {{ timeAgo }}", { + timeAgo: formatDistanceToNowStrict(document.createdAt, { + addSuffix: true, + locale, + }), + ...opts(viewer), + }); + } else { + info = t("{{ user }} created {{ timeAgo }}", { + user: document.createdBy.name, + timeAgo: formatDistanceToNowStrict(document.createdAt, { + addSuffix: true, + locale, + }), + ...opts(viewer), + }); + } + } + + return info; +}; diff --git a/server/presenters/unfurls/document.ts b/server/presenters/unfurls/document.ts new file mode 100644 index 000000000..5cdbc264b --- /dev/null +++ b/server/presenters/unfurls/document.ts @@ -0,0 +1,21 @@ +import { Unfurl, UnfurlType } from "@shared/types"; +import { User, Document } from "@server/models"; +import { presentLastActivityInfoFor } from "./common"; + +function presentDocument( + document: Document, + viewer: User +): Unfurl { + return { + url: document.url, + type: UnfurlType.Document, + title: document.titleWithDefault, + description: presentLastActivityInfoFor(document, viewer), + meta: { + id: document.id, + summary: document.getSummary(), + }, + }; +} + +export default presentDocument; diff --git a/server/presenters/unfurls/index.ts b/server/presenters/unfurls/index.ts new file mode 100644 index 000000000..dbf49bb0c --- /dev/null +++ b/server/presenters/unfurls/index.ts @@ -0,0 +1,4 @@ +import presentDocument from "./document"; +import presentMention from "./mention"; + +export { presentDocument, presentMention }; diff --git a/server/presenters/unfurls/mention.ts b/server/presenters/unfurls/mention.ts new file mode 100644 index 000000000..3733e8a48 --- /dev/null +++ b/server/presenters/unfurls/mention.ts @@ -0,0 +1,23 @@ +import { Unfurl, UnfurlType } from "@shared/types"; +import { Document, User } from "@server/models"; +import { presentLastOnlineInfoFor, presentLastViewedInfoFor } from "./common"; + +function presentMention( + user: User, + document: Document +): Unfurl { + return { + type: UnfurlType.Mention, + title: user.name, + description: `${presentLastOnlineInfoFor( + user + )} • ${presentLastViewedInfoFor(user, document)}`, + thumbnailUrl: user.avatarUrl, + meta: { + id: user.id, + color: user.color, + }, + }; +} + +export default presentMention; diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 604a9b9d5..0622bda50 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -32,6 +32,7 @@ import shares from "./shares"; import stars from "./stars"; import subscriptions from "./subscriptions"; import teams from "./teams"; +import urls from "./urls"; import users from "./users"; import views from "./views"; @@ -86,6 +87,7 @@ router.use("/", attachments.routes()); router.use("/", cron.routes()); router.use("/", groups.routes()); router.use("/", fileOperationsRoute.routes()); +router.use("/", urls.routes()); if (env.ENVIRONMENT === "development") { router.use("/", developer.routes()); diff --git a/server/routes/api/urls/index.ts b/server/routes/api/urls/index.ts new file mode 100644 index 000000000..a9ef488c5 --- /dev/null +++ b/server/routes/api/urls/index.ts @@ -0,0 +1 @@ +export { default } from "./urls"; diff --git a/server/routes/api/urls/schema.ts b/server/routes/api/urls/schema.ts new file mode 100644 index 000000000..ce7857c1b --- /dev/null +++ b/server/routes/api/urls/schema.ts @@ -0,0 +1,36 @@ +import { isNil } from "lodash"; +import { z } from "zod"; +import { isUrl } from "@shared/utils/urls"; +import { ValidateURL } from "@server/validation"; +import BaseSchema from "../BaseSchema"; + +export const UrlsUnfurlSchema = BaseSchema.extend({ + body: z + .object({ + url: z + .string() + .url() + .refine( + (val) => { + try { + const url = new URL(val); + if (url.protocol === "mention:") { + return ValidateURL.isValidMentionUrl(val); + } + return isUrl(val); + } catch (err) { + return false; + } + }, + { message: ValidateURL.message } + ), + documentId: z.string().uuid().optional(), + }) + .refine( + (val) => + !(ValidateURL.isValidMentionUrl(val.url) && isNil(val.documentId)), + { message: "documentId required" } + ), +}); + +export type UrlsUnfurlReq = z.infer; diff --git a/server/routes/api/urls/urls.test.ts b/server/routes/api/urls/urls.test.ts new file mode 100644 index 000000000..5abc44f77 --- /dev/null +++ b/server/routes/api/urls/urls.test.ts @@ -0,0 +1,140 @@ +import { User } from "@server/models"; +import { buildDocument, buildUser } from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("#urls.unfurl", () => { + let user: User; + beforeEach(async () => { + user = await buildUser(); + }); + + it("should fail with status 400 bad request when url is invalid", async () => { + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: "/doc/foo-bar", + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("url: Invalid url"); + }); + + it("should fail with status 400 bad request when mention url is invalid", async () => { + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: "mention://1/foo/1", + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("url: Must be a valid url"); + }); + + it("should fail with status 400 bad request when mention url is supplied without documentId", async () => { + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: "mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64", + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("body: documentId required"); + }); + + it("should fail with status 404 not found when mention user does not exist", async () => { + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: "mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64", + documentId: "2767ba0e-ac5c-4533-b9cf-4f5fc456600e", + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(404); + expect(body.message).toEqual("Mentioned user does not exist"); + }); + + it("should fail with status 404 not found when document does not exist", async () => { + const mentionedUser = await buildUser({ + teamId: user.teamId, + }); + + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: `mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/${mentionedUser.id}`, + documentId: "2767ba0e-ac5c-4533-b9cf-4f5fc456600e", + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(404); + expect(body.message).toEqual("Document does not exist"); + }); + + it("should fail with status 403 forbidden when user is not authorized to read mentioned user info", async () => { + const mentionedUser = await buildUser(); + const document = await buildDocument({ + teamId: user.teamId, + }); + + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: `mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/${mentionedUser.id}`, + documentId: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body.message).toEqual("Authorization error"); + }); + + it("should succeed with status 200 ok when valid mention url is supplied", async () => { + const mentionedUser = await buildUser({ teamId: user.teamId }); + const document = await buildDocument({ + teamId: user.teamId, + }); + + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: `mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/${mentionedUser.id}`, + documentId: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.type).toEqual("mention"); + expect(body.title).toEqual(mentionedUser.name); + expect(body.meta.id).toEqual(mentionedUser.id); + }); + + it("should succeed with status 200 ok when valid document url is supplied", async () => { + const document = await buildDocument({ + teamId: user.teamId, + }); + + const res = await server.post("/api/urls.unfurl", { + body: { + token: user.getJwtToken(), + url: `http://localhost:3000/${document.url}`, + documentId: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.type).toEqual("document"); + expect(body.title).toEqual(document.titleWithDefault); + expect(body.meta.id).toEqual(document.id); + }); +}); diff --git a/server/routes/api/urls/urls.ts b/server/routes/api/urls/urls.ts new file mode 100644 index 000000000..58ebeaddc --- /dev/null +++ b/server/routes/api/urls/urls.ts @@ -0,0 +1,64 @@ +import Router from "koa-router"; +import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; +import parseMentionUrl from "@shared/utils/parseMentionUrl"; +import { NotFoundError } from "@server/errors"; +import auth from "@server/middlewares/authentication"; +import validate from "@server/middlewares/validate"; +import { Document, User } from "@server/models"; +import { authorize } from "@server/policies"; +import { presentDocument, presentMention } from "@server/presenters/unfurls"; +import { APIContext } from "@server/types"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "urls.unfurl", + auth(), + validate(T.UrlsUnfurlSchema), + async (ctx: APIContext) => { + const { url, documentId } = ctx.input.body; + const { user: actor } = ctx.state.auth; + const urlObj = new URL(url); + + if (urlObj.protocol === "mention:") { + const { modelId: userId } = parseMentionUrl(url); + + const [user, document] = await Promise.all([ + User.findByPk(userId), + Document.findByPk(documentId!, { + userId, + }), + ]); + if (!user) { + throw NotFoundError("Mentioned user does not exist"); + } + if (!document) { + throw NotFoundError("Document does not exist"); + } + authorize(actor, "read", user); + authorize(actor, "read", document); + + ctx.body = presentMention(user, document); + return; + } + + const previewDocumentId = parseDocumentSlug(url); + if (!previewDocumentId) { + ctx.response.status = 204; + return; + } + + const document = previewDocumentId + ? await Document.findByPk(previewDocumentId) + : undefined; + if (!document) { + throw NotFoundError("Document does not exist"); + } + authorize(actor, "read", document); + + ctx.body = presentDocument(document, actor); + } +); + +export default router; diff --git a/server/validation.ts b/server/validation.ts index a8e9dd0ea..797d7f410 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -2,7 +2,9 @@ import { isArrayLike } from "lodash"; import { Primitive } from "utility-types"; import validator from "validator"; import isUUID from "validator/lib/isUUID"; +import parseMentionUrl from "@shared/utils/parseMentionUrl"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; +import { isUrl } from "@shared/utils/urls"; import { CollectionPermission } from "../shared/types"; import { validateColorHex } from "../shared/utils/color"; import { validateIndexCharacters } from "../shared/utils/indexCharacters"; @@ -186,3 +188,24 @@ export class ValidateIndex { public static regex = new RegExp("^[\x20-\x7E]+$"); public static message = "Must be between x20 to x7E ASCII"; } + +export class ValidateURL { + public static isValidMentionUrl = (url: string) => { + if (!isUrl(url)) { + return false; + } + try { + const urlObj = new URL(url); + if (urlObj.protocol !== "mention:") { + return false; + } + + const { id, mentionType, modelId } = parseMentionUrl(url); + return id && isUUID(id) && mentionType === "user" && isUUID(modelId); + } catch (err) { + return false; + } + }; + + public static message = "Must be a valid url"; +} diff --git a/shared/editor/nodes/Mention.ts b/shared/editor/nodes/Mention.ts index 00738fa6a..fe7e01a59 100644 --- a/shared/editor/nodes/Mention.ts +++ b/shared/editor/nodes/Mention.ts @@ -5,7 +5,8 @@ import { NodeType, Schema, } from "prosemirror-model"; -import { Command, TextSelection } from "prosemirror-state"; +import { Command, Plugin, TextSelection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; import { Primitive } from "utility-types"; import Suggestion from "../extensions/Suggestion"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; @@ -68,6 +69,7 @@ export default class Mention extends Suggestion { "data-type": node.attrs.type, "data-id": node.attrs.modelId, "data-actorId": node.attrs.actorId, + "data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`, }, node.attrs.label, ], @@ -79,6 +81,31 @@ export default class Mention extends Suggestion { return [mentionRule]; } + get plugins(): Plugin[] { + return [ + new Plugin({ + props: { + handleDOMEvents: { + mouseover: (view: EditorView, event: MouseEvent) => { + const target = (event.target as HTMLElement)?.closest("span"); + if ( + target instanceof HTMLSpanElement && + this.editor.elementRef.current?.contains(target) && + !target.className.includes("ProseMirror-widget") && + (!view.editable || (view.editable && !view.hasFocus())) + ) { + if (this.options.onHoverLink) { + return this.options.onHoverLink(target); + } + } + return false; + }, + }, + }, + }), + ]; + } + commands({ type }: { type: NodeType; schema: Schema }) { return (attrs: Record): Command => (state, dispatch) => { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index fc8ef837a..7611565b6 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -899,5 +899,14 @@ "Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.", "Inactive": "Inactive", "Create a webhook": "Create a webhook", + "Never logged in": "Never logged in", + "Online now": "Online now", + "Online {{ timeAgo }}": "Online {{ timeAgo }}", + "Viewed just now": "Viewed just now", + "Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}", + "You updated {{ timeAgo }}": "You updated {{ timeAgo }}", + "{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}", + "You created {{ timeAgo }}": "You created {{ timeAgo }}", + "{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}", "Uploading": "Uploading" } diff --git a/shared/types.ts b/shared/types.ts index 9c2e8f9a5..f61807898 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -209,5 +209,19 @@ export const NotificationEventDefaults = { [NotificationEventType.ExportCompleted]: true, }; +export enum UnfurlType { + Mention = "mention", + Document = "document", +} + +export type Unfurl = { + url?: string; + type: T; + title: string; + description: string; + thumbnailUrl?: string | null; + meta: Record; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ProsemirrorData = Record; diff --git a/shared/utils/date.ts b/shared/utils/date.ts index bb79796a3..2645582ce 100644 --- a/shared/utils/date.ts +++ b/shared/utils/date.ts @@ -1,4 +1,24 @@ +/* eslint-disable import/no-duplicates */ import { subDays, subMonths, subWeeks, subYears } from "date-fns"; +import { + de, + enUS, + es, + faIR, + fr, + it, + ja, + ko, + nl, + ptBR, + pt, + pl, + ru, + tr, + vi, + zhCN, + zhTW, +} from "date-fns/locale"; import type { DateFilter } from "../types"; export function subtractDate(date: Date, period: DateFilter) { @@ -80,3 +100,35 @@ export function getCurrentDateTimeAsString(locales?: Intl.LocalesArgument) { minute: "numeric", }); } + +const locales = { + de_DE: de, + en_US: enUS, + es_ES: es, + fa_IR: faIR, + fr_FR: fr, + it_IT: it, + ja_JP: ja, + ko_KR: ko, + nl_NL: nl, + pt_BR: ptBR, + pt_PT: pt, + pl_PL: pl, + ru_RU: ru, + tr_TR: tr, + vi_VN: vi, + zh_CN: zhCN, + zh_TW: zhTW, +}; + +/** + * Returns the date-fns locale object for the given user language preference. + * + * @param language The user language + * @returns The date-fns locale. + */ +export function dateLocale(language: string | null | undefined) { + return language ? locales[language] : undefined; +} + +export { locales }; diff --git a/shared/utils/parseMentionUrl.ts b/shared/utils/parseMentionUrl.ts new file mode 100644 index 000000000..f4ab80607 --- /dev/null +++ b/shared/utils/parseMentionUrl.ts @@ -0,0 +1,12 @@ +const parseMentionUrl = (url: string) => { + const matches = url.match( + /^mention:\/\/([a-z0-9-]+)\/([a-z]+)\/([a-z0-9-]+)$/ + ); + if (!matches) { + return {}; + } + const [id, mentionType, modelId] = matches.slice(1); + return { id, mentionType, modelId }; +}; + +export default parseMentionUrl;