mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
549 lines
14 KiB
TypeScript
549 lines
14 KiB
TypeScript
import { observer } from "mobx-react";
|
|
import {
|
|
DocumentIcon,
|
|
EmailIcon,
|
|
CollectionIcon,
|
|
WarningIcon,
|
|
} from "outline-icons";
|
|
import type { Node } from "prosemirror-model";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import styled from "styled-components";
|
|
import { Backticks } from "../../components/Backticks";
|
|
import Flex from "../../components/Flex";
|
|
import Icon from "../../components/Icon";
|
|
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
|
|
import { PullRequestIcon } from "../../components/PullRequestIcon";
|
|
import Spinner from "../../components/Spinner";
|
|
import Text from "../../components/Text";
|
|
import useIsMounted from "../../hooks/useIsMounted";
|
|
import useStores from "../../hooks/useStores";
|
|
import theme from "../../styles/theme";
|
|
import {
|
|
IntegrationService,
|
|
UnfurlResourceType,
|
|
type JSONValue,
|
|
type UnfurlResponse,
|
|
} from "../../types";
|
|
import { cn } from "../styles/utils";
|
|
import type { ComponentProps } from "../types";
|
|
import { toDisplayUrl, cdnPath } from "../../utils/urls";
|
|
import Squircle from "../../components/Squircle";
|
|
|
|
type Attrs = {
|
|
className: string;
|
|
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
|
} & Record<string, JSONValue>;
|
|
|
|
const getAttributesFromNode = (node: Node): Attrs => {
|
|
const spec = node.type.spec.toDOM?.(node) as unknown as Record<
|
|
string,
|
|
JSONValue
|
|
>[];
|
|
const { class: className, "data-unfurl": unfurl, ...attrs } = spec[1];
|
|
|
|
return {
|
|
className: className as Attrs["className"],
|
|
unfurl: unfurl
|
|
? (JSON.parse(unfurl as string) as Attrs["unfurl"])
|
|
: undefined,
|
|
...attrs,
|
|
};
|
|
};
|
|
|
|
export const MentionUser = observer(function MentionUser_(
|
|
props: ComponentProps
|
|
) {
|
|
const { isSelected, node } = props;
|
|
const { users } = useStores();
|
|
const user = users.get(node.attrs.modelId);
|
|
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
|
|
|
return (
|
|
<span
|
|
{...attrs}
|
|
className={cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
})}
|
|
>
|
|
<EmailIcon size={18} />
|
|
{user?.name || node.attrs.label}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
export const MentionGroup = observer(function MentionGroup_(
|
|
props: ComponentProps
|
|
) {
|
|
const { isSelected, node } = props;
|
|
const { groups } = useStores();
|
|
const group = groups.get(node.attrs.modelId);
|
|
const { className, ...attrs } = getAttributesFromNode(node);
|
|
|
|
return (
|
|
<span
|
|
{...attrs}
|
|
className={cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
})}
|
|
>
|
|
<EmailIcon size={18} />
|
|
{group?.name || node.attrs.label}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
export const MentionDocument = observer(function MentionDocument_(
|
|
props: ComponentProps
|
|
) {
|
|
const { isSelected, node } = props;
|
|
const { documents } = useStores();
|
|
const doc = documents.get(node.attrs.modelId);
|
|
const modelId = node.attrs.modelId;
|
|
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
|
|
|
React.useEffect(() => {
|
|
if (modelId) {
|
|
void documents.prefetchDocument(modelId);
|
|
}
|
|
}, [modelId, documents]);
|
|
|
|
return (
|
|
<Link
|
|
{...attrs}
|
|
className={cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
})}
|
|
to={doc?.path ?? `/doc/${node.attrs.modelId}`}
|
|
>
|
|
{doc?.icon ? (
|
|
<Icon
|
|
value={doc.icon}
|
|
initial={doc.initial}
|
|
color={doc.color}
|
|
size={18}
|
|
/>
|
|
) : (
|
|
<DocumentIcon size={18} />
|
|
)}
|
|
{doc?.title || node.attrs.label}
|
|
</Link>
|
|
);
|
|
});
|
|
|
|
export const MentionCollection = observer(function MentionCollection_(
|
|
props: ComponentProps
|
|
) {
|
|
const { isSelected, node } = props;
|
|
const { collections } = useStores();
|
|
const collection = collections.get(node.attrs.modelId);
|
|
const modelId = node.attrs.modelId;
|
|
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
|
|
|
React.useEffect(() => {
|
|
if (modelId) {
|
|
void collections.fetch(modelId);
|
|
}
|
|
}, [modelId, collections]);
|
|
|
|
return (
|
|
<Link
|
|
{...attrs}
|
|
className={cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
})}
|
|
to={collection?.path ?? `/collection/${node.attrs.modelId}`}
|
|
>
|
|
{collection?.icon ? (
|
|
<Icon
|
|
value={collection.icon}
|
|
initial={collection.initial}
|
|
color={collection.color}
|
|
size={18}
|
|
/>
|
|
) : (
|
|
<CollectionIcon size={18} />
|
|
)}
|
|
{collection?.title || node.attrs.label}
|
|
</Link>
|
|
);
|
|
});
|
|
|
|
type IssuePrProps = ComponentProps & {
|
|
onChangeUnfurl: (
|
|
unfurl:
|
|
| UnfurlResponse[UnfurlResourceType.Issue]
|
|
| UnfurlResponse[UnfurlResourceType.PR]
|
|
) => void;
|
|
};
|
|
|
|
type IssueUrlProps = ComponentProps & {
|
|
onChangeUnfurl: (unfurl: UnfurlResponse[UnfurlResourceType.URL]) => void;
|
|
};
|
|
|
|
export const MentionURL = (props: IssueUrlProps) => {
|
|
const { unfurls } = useStores();
|
|
const isMounted = useIsMounted();
|
|
const [loaded, setLoaded] = React.useState(false);
|
|
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
|
|
|
const { isSelected, node } = props;
|
|
const {
|
|
className,
|
|
unfurl: unfurlAttr,
|
|
...attrs
|
|
} = getAttributesFromNode(node);
|
|
|
|
const url = typeof attrs.href === "string" ? attrs.href : undefined;
|
|
const unfurl = url ? (unfurls.get(url)?.data ?? unfurlAttr) : undefined;
|
|
|
|
React.useEffect(() => {
|
|
if (!url) {
|
|
setLoaded(true);
|
|
return;
|
|
}
|
|
|
|
const fetchUnfurl = async () => {
|
|
try {
|
|
const unfurlModel = await unfurls.fetchUnfurl({ url });
|
|
|
|
if (!isMounted()) {
|
|
return;
|
|
}
|
|
|
|
// We got a result back from the server, so update the unfurl in the node attributes.
|
|
if (unfurlModel) {
|
|
onChangeUnfurl(
|
|
unfurlModel.data satisfies UnfurlResponse[UnfurlResourceType.URL]
|
|
);
|
|
return;
|
|
}
|
|
|
|
const attrs = getAttributesFromNode(node);
|
|
// If we have a unfurl attribute, use that.
|
|
// Otherwise, set a basic unfurl to avoid refetching again in future.
|
|
// This will just show the URL with a generic link icon.
|
|
const data = attrs.unfurl
|
|
? attrs.unfurl
|
|
: {
|
|
title: toDisplayUrl(url),
|
|
faviconUrl: cdnPath("/images/link.png"),
|
|
};
|
|
unfurls.add({
|
|
id: url,
|
|
type: UnfurlResourceType.URL,
|
|
fetchedAt: new Date().toISOString(),
|
|
data,
|
|
});
|
|
} finally {
|
|
if (isMounted()) {
|
|
setLoaded(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
void fetchUnfurl();
|
|
}, [unfurls, url, node, isMounted, onChangeUnfurl]);
|
|
|
|
if (!unfurl) {
|
|
return !loaded ? (
|
|
<MentionLoading className={className} />
|
|
) : (
|
|
<MentionError className={className} />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<a
|
|
{...attrs}
|
|
className={cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
})}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
>
|
|
<Flex align="center" gap={6}>
|
|
{unfurl.faviconUrl ? <Logo src={unfurl.faviconUrl} alt="" /> : null}
|
|
<Text>
|
|
<Backticks content={unfurl.title} />
|
|
</Text>
|
|
</Flex>
|
|
</a>
|
|
);
|
|
};
|
|
|
|
export const MentionIssue = observer((props: IssuePrProps) => {
|
|
const { unfurls } = useStores();
|
|
const isMounted = useIsMounted();
|
|
const [loaded, setLoaded] = React.useState(false);
|
|
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
|
|
|
const { isSelected, node } = props;
|
|
const {
|
|
className,
|
|
unfurl: unfurlAttr,
|
|
...attrs
|
|
} = getAttributesFromNode(node);
|
|
|
|
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
|
|
|
React.useEffect(() => {
|
|
const fetchIssue = async () => {
|
|
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
|
|
|
if (!isMounted()) {
|
|
return;
|
|
}
|
|
|
|
if (unfurlModel) {
|
|
onChangeUnfurl({
|
|
...unfurlModel.data,
|
|
description: null,
|
|
} satisfies UnfurlResponse[UnfurlResourceType.Issue]);
|
|
}
|
|
|
|
setLoaded(true);
|
|
};
|
|
|
|
void fetchIssue();
|
|
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
|
|
|
if (!unfurl) {
|
|
return !loaded ? (
|
|
<MentionLoading className={className} />
|
|
) : (
|
|
<MentionError className={className} />
|
|
);
|
|
}
|
|
|
|
const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue];
|
|
|
|
let service = IntegrationService.GitLab;
|
|
try {
|
|
const parsedUrl = new URL(issue.url);
|
|
service =
|
|
parsedUrl.hostname === "github.com"
|
|
? IntegrationService.GitHub
|
|
: parsedUrl.hostname === "linear.app"
|
|
? IntegrationService.Linear
|
|
: IntegrationService.GitLab;
|
|
} catch {
|
|
// Invalid URL in unfurl data, default to GitLab
|
|
}
|
|
|
|
return (
|
|
<a
|
|
{...attrs}
|
|
className={cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
})}
|
|
href={attrs.href as string}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
>
|
|
<Flex align="center" gap={6}>
|
|
<IssueStatusIcon size={14} service={service} state={issue.state} />
|
|
<Flex align="center" gap={4}>
|
|
<Text>
|
|
<Backticks content={issue.title} />
|
|
</Text>
|
|
<Text type="tertiary">{issue.id}</Text>
|
|
</Flex>
|
|
</Flex>
|
|
</a>
|
|
);
|
|
});
|
|
|
|
type ProjectProps = ComponentProps & {
|
|
onChangeUnfurl: (unfurl: UnfurlResponse[UnfurlResourceType.Project]) => void;
|
|
};
|
|
|
|
export const MentionProject = observer((props: ProjectProps) => {
|
|
const { unfurls } = useStores();
|
|
const isMounted = useIsMounted();
|
|
const [loaded, setLoaded] = React.useState(false);
|
|
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current;
|
|
|
|
const { isSelected, node } = props;
|
|
const {
|
|
className,
|
|
unfurl: unfurlAttr,
|
|
...attrs
|
|
} = getAttributesFromNode(node);
|
|
|
|
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
|
|
|
React.useEffect(() => {
|
|
const fetchProject = async () => {
|
|
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
|
|
|
if (!isMounted()) {
|
|
return;
|
|
}
|
|
|
|
if (unfurlModel) {
|
|
onChangeUnfurl({
|
|
...unfurlModel.data,
|
|
description: null,
|
|
} satisfies UnfurlResponse[UnfurlResourceType.Project]);
|
|
}
|
|
|
|
setLoaded(true);
|
|
};
|
|
|
|
void fetchProject();
|
|
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
|
|
|
if (!unfurl) {
|
|
return !loaded ? (
|
|
<MentionLoading className={className} />
|
|
) : (
|
|
<MentionError className={className} />
|
|
);
|
|
}
|
|
|
|
const project = unfurl as UnfurlResponse[UnfurlResourceType.Project];
|
|
|
|
return (
|
|
<a
|
|
{...attrs}
|
|
className={cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
})}
|
|
href={attrs.href as string}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
>
|
|
<Flex align="center" gap={6}>
|
|
{project.avatarUrl ? (
|
|
<ProjectAvatar src={project.avatarUrl} alt="" />
|
|
) : (
|
|
<Squircle color={project.color} size={12} />
|
|
)}
|
|
<Flex align="center" gap={4}>
|
|
<Text>
|
|
<Backticks content={project.name} />
|
|
</Text>
|
|
<Text type="tertiary">
|
|
{project.progress !== undefined
|
|
? `${Math.round(project.progress * 100)}%`
|
|
: project.id}
|
|
</Text>
|
|
</Flex>
|
|
</Flex>
|
|
</a>
|
|
);
|
|
});
|
|
|
|
export const MentionPullRequest = observer((props: IssuePrProps) => {
|
|
const { unfurls } = useStores();
|
|
const isMounted = useIsMounted();
|
|
const [loaded, setLoaded] = React.useState(false);
|
|
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
|
|
|
const { isSelected, node } = props;
|
|
const {
|
|
className,
|
|
unfurl: unfurlAttr,
|
|
...attrs
|
|
} = getAttributesFromNode(node);
|
|
|
|
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
|
|
|
React.useEffect(() => {
|
|
const fetchPR = async () => {
|
|
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
|
|
|
if (!isMounted()) {
|
|
return;
|
|
}
|
|
|
|
if (unfurlModel) {
|
|
onChangeUnfurl({
|
|
...unfurlModel.data,
|
|
description: null,
|
|
} satisfies UnfurlResponse[UnfurlResourceType.PR]);
|
|
}
|
|
|
|
setLoaded(true);
|
|
};
|
|
|
|
void fetchPR();
|
|
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
|
|
|
const sharedProps = {
|
|
className: cn(className, {
|
|
"ProseMirror-selectednode": isSelected,
|
|
}),
|
|
};
|
|
|
|
if (!unfurl) {
|
|
return !loaded ? (
|
|
<MentionLoading {...sharedProps} />
|
|
) : (
|
|
<MentionError {...sharedProps} />
|
|
);
|
|
}
|
|
|
|
const pullRequest = unfurl as UnfurlResponse[UnfurlResourceType.PR];
|
|
|
|
return (
|
|
<a
|
|
{...attrs}
|
|
{...sharedProps}
|
|
href={attrs.href as string}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
>
|
|
<Flex align="center" gap={6}>
|
|
<PullRequestIcon size={14} state={pullRequest.state} />
|
|
<Flex align="center" gap={4}>
|
|
<Text>
|
|
<Backticks content={pullRequest.title} />
|
|
</Text>
|
|
<Text type="tertiary">{pullRequest.id}</Text>
|
|
</Flex>
|
|
</Flex>
|
|
</a>
|
|
);
|
|
});
|
|
|
|
const MentionLoading = ({ className }: { className: string }) => {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<span className={className}>
|
|
<Spinner />
|
|
<Text type="tertiary">{`${t("Loading")}…`}</Text>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const MentionError = ({ className }: { className: string }) => {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<span className={className}>
|
|
<StyledWarningIcon size={20} color={theme.danger} />
|
|
<Text type="secondary">{`${t("Error loading data")}`}</Text>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const StyledWarningIcon = styled(WarningIcon)`
|
|
margin: 0 -2px;
|
|
`;
|
|
|
|
const Logo = styled.img`
|
|
width: 16px;
|
|
height: 16px;
|
|
`;
|
|
|
|
const ProjectAvatar = styled.img`
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 2px;
|
|
`;
|