@@ -349,7 +349,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
)}
|
||||
{activeLinkElement && !shareId && (
|
||||
<HoverPreview
|
||||
id={props.id}
|
||||
element={activeLinkElement}
|
||||
onClose={handleLinkInactive}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
25
app/components/HoverPreview/Components.ts
Normal file
25
app/components/HoverPreview/Components.ts
Normal 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;
|
||||
`;
|
||||
282
app/components/HoverPreview/HoverPreview.tsx
Normal file
282
app/components/HoverPreview/HoverPreview.tsx
Normal 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;
|
||||
37
app/components/HoverPreview/HoverPreviewDocument.tsx
Normal file
37
app/components/HoverPreview/HoverPreviewDocument.tsx
Normal 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;
|
||||
41
app/components/HoverPreview/HoverPreviewMention.tsx
Normal file
41
app/components/HoverPreview/HoverPreviewMention.tsx
Normal 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;
|
||||
3
app/components/HoverPreview/index.ts
Normal file
3
app/components/HoverPreview/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import HoverPreview from "./HoverPreview";
|
||||
|
||||
export default HoverPreview;
|
||||
@@ -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);
|
||||
@@ -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)[] = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
103
server/presenters/unfurls/common.ts
Normal file
103
server/presenters/unfurls/common.ts
Normal 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;
|
||||
};
|
||||
21
server/presenters/unfurls/document.ts
Normal file
21
server/presenters/unfurls/document.ts
Normal 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;
|
||||
4
server/presenters/unfurls/index.ts
Normal file
4
server/presenters/unfurls/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import presentDocument from "./document";
|
||||
import presentMention from "./mention";
|
||||
|
||||
export { presentDocument, presentMention };
|
||||
23
server/presenters/unfurls/mention.ts
Normal file
23
server/presenters/unfurls/mention.ts
Normal 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;
|
||||
@@ -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());
|
||||
|
||||
1
server/routes/api/urls/index.ts
Normal file
1
server/routes/api/urls/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./urls";
|
||||
36
server/routes/api/urls/schema.ts
Normal file
36
server/routes/api/urls/schema.ts
Normal 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>;
|
||||
140
server/routes/api/urls/urls.test.ts
Normal file
140
server/routes/api/urls/urls.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
server/routes/api/urls/urls.ts
Normal file
64
server/routes/api/urls/urls.ts
Normal 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;
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
12
shared/utils/parseMentionUrl.ts
Normal file
12
shared/utils/parseMentionUrl.ts
Normal 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;
|
||||
Reference in New Issue
Block a user