Preview mentions (#5571)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2023-07-22 21:43:09 +05:30
committed by GitHub
parent dbd85d62cb
commit 5d71398ea6
27 changed files with 923 additions and 361 deletions

View File

@@ -349,7 +349,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
)}
{activeLinkElement && !shareId && (
<HoverPreview
id={props.id}
element={activeLinkElement}
onClose={handleLinkInactive}
/>

View File

@@ -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<ReturnType<typeof setTimeout>>();
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(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 (
<Portal>
<Position
top={anchorBounds.bottom + window.scrollY}
left={left}
aria-hidden
>
<div ref={cardRef}>
<HoverPreviewDocument url={element.href} id={id}>
{(content: React.ReactNode) =>
isVisible ? (
<Animate>
<Card>
<Margin />
<CardContent>{content}</CardContent>
</Card>
<Pointer offset={leftOffset + anchorBounds.width / 2} />
</Animate>
) : null
}
</HoverPreviewDocument>
</div>
</Position>
</Portal>
);
}
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 <HoverPreviewInternal {...rest} element={element} />;
}
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;

View File

@@ -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;
`;

View File

@@ -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<ReturnType<typeof setTimeout>>();
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(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 <LoadingIndicator />;
}
if (!data) {
return null;
}
return (
<Portal>
<Position
top={elemBounds.bottom + window.scrollY}
left={left}
aria-hidden
>
<div ref={cardRef}>
{isVisible ? (
<Animate>
<Card fadeOut={data.type !== UnfurlType.Mention}>
<Margin />
<CardContent>
{data.type === UnfurlType.Mention ? (
<HoverPreviewMention
url={data.thumbnailUrl}
title={data.title}
description={data.description}
color={data.meta.color}
/>
) : data.type === UnfurlType.Document ? (
<HoverPreviewDocument
id={data.meta.id}
url={data.url}
title={data.title}
description={data.description}
summary={data.meta.summary}
/>
) : null}
</CardContent>
</Card>
<Pointer offset={leftOffset + elemBounds.width / 2} />
</Animate>
) : null}
</div>
</Position>
</Portal>
);
}
function HoverPreview({ element, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
return <HoverPreviewInternal {...rest} element={element} />;
}
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;

View File

@@ -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 (
<Preview to={url}>
<Flex column>
<Title>{title}</Title>
<Description type="tertiary" size="xsmall">
{description}
</Description>
<Summary>
<React.Suspense fallback={<div />}>
<Editor key={id} defaultValue={summary} embedsDisabled readOnly />
</React.Suspense>
</Summary>
</Flex>
</Preview>
);
}
export default HoverPreviewDocument;

View File

@@ -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 (
<Preview to="">
<Flex gap={12}>
<Avatar
model={{
avatarUrl: url,
initial: title ? title[0] : "?",
color,
}}
size={AvatarSize.XLarge}
/>
<Flex column>
<Title>{title}</Title>
<Description type="tertiary" size="xsmall">
{description}
</Description>
</Flex>
</Flex>
</Preview>
);
}
export default HoverPreviewMention;

View File

@@ -0,0 +1,3 @@
import HoverPreview from "./HoverPreview";
export default HoverPreview;

View File

@@ -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(
<Content to={document.url}>
<Heading>{document.titleWithDefault}</Heading>
<DocumentMeta document={document} />
<React.Suspense fallback={<div />}>
<Editor
key={document.id}
defaultValue={document.getSummary()}
embedsDisabled
readOnly
/>
</React.Suspense>
</Content>
)}
</>
);
}
const Content = styled(Link)`
cursor: var(--pointer);
`;
const Heading = styled.h2`
margin: 0 0 0.75em;
color: ${s("text")};
`;
export default observer(HoverPreviewDocument);

View File

@@ -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)[] = [];

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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;
};

View File

@@ -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<UnfurlType.Document> {
return {
url: document.url,
type: UnfurlType.Document,
title: document.titleWithDefault,
description: presentLastActivityInfoFor(document, viewer),
meta: {
id: document.id,
summary: document.getSummary(),
},
};
}
export default presentDocument;

View File

@@ -0,0 +1,4 @@
import presentDocument from "./document";
import presentMention from "./mention";
export { presentDocument, presentMention };

View File

@@ -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<UnfurlType.Mention> {
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;

View File

@@ -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());

View File

@@ -0,0 +1 @@
export { default } from "./urls";

View File

@@ -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<typeof UrlsUnfurlSchema>;

View File

@@ -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);
});
});

View File

@@ -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<T.UrlsUnfurlReq>) => {
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;

View File

@@ -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";
}

View File

@@ -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<string, Primitive>): Command =>
(state, dispatch) => {

View File

@@ -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"
}

View File

@@ -209,5 +209,19 @@ export const NotificationEventDefaults = {
[NotificationEventType.ExportCompleted]: true,
};
export enum UnfurlType {
Mention = "mention",
Document = "document",
}
export type Unfurl<T = unknown> = {
url?: string;
type: T;
title: string;
description: string;
thumbnailUrl?: string | null;
meta: Record<string, string>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ProsemirrorData = Record<string, any>;

View File

@@ -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 };

View File

@@ -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;