Refactor unfurling related types (#6730)
* wip * fix: refactor unfurl types
This commit is contained in:
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import { Portal } from "react-portal";
|
import { Portal } from "react-portal";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { depths } from "@shared/styles";
|
import { depths } from "@shared/styles";
|
||||||
import { UnfurlType } from "@shared/types";
|
import { UnfurlResourceType } from "@shared/types";
|
||||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import useEventListener from "~/hooks/useEventListener";
|
import useEventListener from "~/hooks/useEventListener";
|
||||||
@@ -120,41 +120,41 @@ function HoverPreviewDesktop({ element, onClose }: Props) {
|
|||||||
transitionEnd: { pointerEvents: "auto" },
|
transitionEnd: { pointerEvents: "auto" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.type === UnfurlType.Mention ? (
|
{data.type === UnfurlResourceType.Mention ? (
|
||||||
<HoverPreviewMention
|
<HoverPreviewMention
|
||||||
url={data.thumbnailUrl}
|
name={data.name}
|
||||||
title={data.title}
|
avatarUrl={data.avatarUrl}
|
||||||
info={data.meta.info}
|
color={data.color}
|
||||||
color={data.meta.color}
|
lastActive={data.lastActive}
|
||||||
/>
|
/>
|
||||||
) : data.type === UnfurlType.Document ? (
|
) : data.type === UnfurlResourceType.Document ? (
|
||||||
<HoverPreviewDocument
|
<HoverPreviewDocument
|
||||||
id={data.meta.id}
|
|
||||||
url={data.url}
|
url={data.url}
|
||||||
|
id={data.id}
|
||||||
title={data.title}
|
title={data.title}
|
||||||
description={data.description}
|
summary={data.summary}
|
||||||
info={data.meta.info}
|
lastActivityByViewer={data.lastActivityByViewer}
|
||||||
/>
|
/>
|
||||||
) : data.type === UnfurlType.Issue ? (
|
) : data.type === UnfurlResourceType.Issue ? (
|
||||||
<HoverPreviewIssue
|
<HoverPreviewIssue
|
||||||
url={data.url}
|
url={data.url}
|
||||||
|
id={data.id}
|
||||||
title={data.title}
|
title={data.title}
|
||||||
description={data.description}
|
description={data.description}
|
||||||
author={data.author}
|
author={data.author}
|
||||||
|
labels={data.labels}
|
||||||
|
state={data.state}
|
||||||
createdAt={data.createdAt}
|
createdAt={data.createdAt}
|
||||||
identifier={data.meta.identifier}
|
|
||||||
labels={data.meta.labels}
|
|
||||||
status={data.meta.status}
|
|
||||||
/>
|
/>
|
||||||
) : data.type === UnfurlType.Pull ? (
|
) : data.type === UnfurlResourceType.PR ? (
|
||||||
<HoverPreviewPullRequest
|
<HoverPreviewPullRequest
|
||||||
url={data.url}
|
url={data.url}
|
||||||
|
id={data.id}
|
||||||
title={data.title}
|
title={data.title}
|
||||||
description={data.description}
|
description={data.description}
|
||||||
author={data.author}
|
author={data.author}
|
||||||
createdAt={data.createdAt}
|
createdAt={data.createdAt}
|
||||||
identifier={data.meta.identifier}
|
state={data.state}
|
||||||
status={data.meta.status}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HoverPreviewLink
|
<HoverPreviewLink
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import {
|
import {
|
||||||
@@ -10,21 +11,10 @@ import {
|
|||||||
Description,
|
Description,
|
||||||
} from "./Components";
|
} from "./Components";
|
||||||
|
|
||||||
type Props = {
|
type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">;
|
||||||
/** Document id associated with the editor, if any */
|
|
||||||
id?: string;
|
|
||||||
/** Document url */
|
|
||||||
url: string;
|
|
||||||
/** Title for the preview card */
|
|
||||||
title: string;
|
|
||||||
/** Info about last activity on the document */
|
|
||||||
info: string;
|
|
||||||
/** Text preview of document content */
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
||||||
{ id, url, title, info, description }: Props,
|
{ url, id, title, summary, lastActivityByViewer }: Props,
|
||||||
ref: React.Ref<HTMLDivElement>
|
ref: React.Ref<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -33,12 +23,12 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Flex column gap={2}>
|
<Flex column gap={2}>
|
||||||
<Title>{title}</Title>
|
<Title>{title}</Title>
|
||||||
<Info>{info}</Info>
|
<Info>{lastActivityByViewer}</Info>
|
||||||
<Description as="div">
|
<Description as="div">
|
||||||
<React.Suspense fallback={<div />}>
|
<React.Suspense fallback={<div />}>
|
||||||
<Editor
|
<Editor
|
||||||
key={id}
|
key={id}
|
||||||
defaultValue={description}
|
defaultValue={summary}
|
||||||
embedsDisabled
|
embedsDisabled
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
|
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Avatar from "../Avatar";
|
import Avatar from "../Avatar";
|
||||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||||
@@ -15,36 +16,10 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
} from "./Components";
|
} from "./Components";
|
||||||
|
|
||||||
type Props = {
|
type Props = Omit<UnfurlResponse[UnfurlResourceType.Issue], "type">;
|
||||||
/** Issue url */
|
|
||||||
url: string;
|
|
||||||
/** Issue title */
|
|
||||||
title: string;
|
|
||||||
/** Issue description */
|
|
||||||
description: string;
|
|
||||||
/** Wehn the issue was created */
|
|
||||||
createdAt: string;
|
|
||||||
/** Author of the issue */
|
|
||||||
author: { name: string; avatarUrl: string };
|
|
||||||
/** Labels attached to the issue */
|
|
||||||
labels: Array<{ name: string; color: string }>;
|
|
||||||
/** Issue status */
|
|
||||||
status: { name: string; color: string };
|
|
||||||
/** Issue identifier */
|
|
||||||
identifier: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||||
{
|
{ url, id, title, description, author, labels, state, createdAt }: Props,
|
||||||
url,
|
|
||||||
title,
|
|
||||||
identifier,
|
|
||||||
description,
|
|
||||||
author,
|
|
||||||
labels,
|
|
||||||
status,
|
|
||||||
createdAt,
|
|
||||||
}: Props,
|
|
||||||
ref: React.Ref<HTMLDivElement>
|
ref: React.Ref<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
const authorName = author.name;
|
const authorName = author.name;
|
||||||
@@ -56,9 +31,9 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Flex gap={2} column>
|
<Flex gap={2} column>
|
||||||
<Title>
|
<Title>
|
||||||
<IssueStatusIcon status={status.name} color={status.color} />
|
<IssueStatusIcon status={state.name} color={state.color} />
|
||||||
<span>
|
<span>
|
||||||
{title} <Text type="tertiary">{identifier}</Text>
|
{title} <Text type="tertiary">{id}</Text>
|
||||||
</span>
|
</span>
|
||||||
</Title>
|
</Title>
|
||||||
<Flex align="center" gap={4}>
|
<Flex align="center" gap={4}>
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import { AvatarSize } from "~/components/Avatar/Avatar";
|
import { AvatarSize } from "~/components/Avatar/Avatar";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||||
|
|
||||||
type Props = {
|
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||||
/** Resource url, avatar url in case of user mention */
|
|
||||||
url: string;
|
|
||||||
/** Title for the preview card*/
|
|
||||||
title: string;
|
|
||||||
/** Info about mentioned user's recent activity */
|
|
||||||
info: string;
|
|
||||||
/** Used for avatar's background color in absence of avatar url */
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||||
{ url, title, info, color }: Props,
|
{ avatarUrl, name, lastActive, color }: Props,
|
||||||
ref: React.Ref<HTMLDivElement>
|
ref: React.Ref<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -26,15 +18,15 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
|||||||
<Flex gap={12}>
|
<Flex gap={12}>
|
||||||
<Avatar
|
<Avatar
|
||||||
model={{
|
model={{
|
||||||
avatarUrl: url,
|
avatarUrl,
|
||||||
initial: title ? title[0] : "?",
|
initial: name ? name[0] : "?",
|
||||||
color,
|
color,
|
||||||
}}
|
}}
|
||||||
size={AvatarSize.XLarge}
|
size={AvatarSize.XLarge}
|
||||||
/>
|
/>
|
||||||
<Flex column gap={2} justify="center">
|
<Flex column gap={2} justify="center">
|
||||||
<Title>{title}</Title>
|
<Title>{name}</Title>
|
||||||
<Info>{info}</Info>
|
<Info>{lastActive}</Info>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
|
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Avatar from "../Avatar";
|
import Avatar from "../Avatar";
|
||||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||||
@@ -14,26 +15,11 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
} from "./Components";
|
} from "./Components";
|
||||||
|
|
||||||
type Props = {
|
type Props = Omit<UnfurlResponse[UnfurlResourceType.PR], "type">;
|
||||||
/** Pull request url */
|
|
||||||
url: string;
|
|
||||||
/** Pull request title */
|
|
||||||
title: string;
|
|
||||||
/** Pull request description */
|
|
||||||
description: string;
|
|
||||||
/** When the pull request was opened */
|
|
||||||
createdAt: string;
|
|
||||||
/** Author of the pull request */
|
|
||||||
author: { name: string; avatarUrl: string };
|
|
||||||
/** Pull request status */
|
|
||||||
status: { name: string; color: string };
|
|
||||||
/** Pull request identifier */
|
|
||||||
identifier: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HoverPreviewPullRequest = React.forwardRef(
|
const HoverPreviewPullRequest = React.forwardRef(
|
||||||
function _HoverPreviewPullRequest(
|
function _HoverPreviewPullRequest(
|
||||||
{ url, title, identifier, description, author, status, createdAt }: Props,
|
{ url, title, id, description, author, state, createdAt }: Props,
|
||||||
ref: React.Ref<HTMLDivElement>
|
ref: React.Ref<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
const authorName = author.name;
|
const authorName = author.name;
|
||||||
@@ -45,9 +31,9 @@ const HoverPreviewPullRequest = React.forwardRef(
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Flex gap={2} column>
|
<Flex gap={2} column>
|
||||||
<Title>
|
<Title>
|
||||||
<PullRequestIcon status={status.name} color={status.color} />
|
<PullRequestIcon status={state.name} color={state.color} />
|
||||||
<span>
|
<span>
|
||||||
{title} <Text type="tertiary">{identifier}</Text>
|
{title} <Text type="tertiary">{id}</Text>
|
||||||
</span>
|
</span>
|
||||||
</Title>
|
</Title>
|
||||||
<Flex align="center" gap={4}>
|
<Flex align="center" gap={4}>
|
||||||
|
|||||||
@@ -6,45 +6,17 @@ import {
|
|||||||
} from "@octokit/auth-app";
|
} from "@octokit/auth-app";
|
||||||
import { Octokit } from "octokit";
|
import { Octokit } from "octokit";
|
||||||
import pluralize from "pluralize";
|
import pluralize from "pluralize";
|
||||||
import { IntegrationService, IntegrationType, Unfurl } from "@shared/types";
|
import {
|
||||||
|
IntegrationService,
|
||||||
|
IntegrationType,
|
||||||
|
JSONObject,
|
||||||
|
UnfurlResourceType,
|
||||||
|
} from "@shared/types";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
import { Integration, User } from "@server/models";
|
import { Integration, User } from "@server/models";
|
||||||
import { GitHubUtils } from "../shared/GitHubUtils";
|
import { UnfurlSignature } from "@server/types";
|
||||||
import env from "./env";
|
import env from "./env";
|
||||||
|
|
||||||
enum Resource {
|
|
||||||
PR = "pull",
|
|
||||||
Issue = "issue",
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewData = {
|
|
||||||
[Resource.PR]: {
|
|
||||||
url: string;
|
|
||||||
type: Resource.PR;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
author: { name: string; avatarUrl: string };
|
|
||||||
createdAt: string;
|
|
||||||
meta: {
|
|
||||||
identifier: string;
|
|
||||||
status: { name: string; color: string };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
[Resource.Issue]: {
|
|
||||||
url: string;
|
|
||||||
type: Resource.Issue;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
author: { name: string; avatarUrl: string };
|
|
||||||
createdAt: string;
|
|
||||||
meta: {
|
|
||||||
identifier: string;
|
|
||||||
labels: Array<{ name: string; color: string }>;
|
|
||||||
status: { name: string; color: string };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestPlugin = (octokit: Octokit) => ({
|
const requestPlugin = (octokit: Octokit) => ({
|
||||||
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
|
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
|
||||||
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
|
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
|
||||||
@@ -84,11 +56,11 @@ const requestPlugin = (octokit: Octokit) => ({
|
|||||||
*/
|
*/
|
||||||
requestResource: async function requestResource(
|
requestResource: async function requestResource(
|
||||||
resource: ReturnType<typeof GitHub.parseUrl>
|
resource: ReturnType<typeof GitHub.parseUrl>
|
||||||
) {
|
): Promise<{ data?: JSONObject }> {
|
||||||
switch (resource?.type) {
|
switch (resource?.type) {
|
||||||
case Resource.PR:
|
case UnfurlResourceType.PR:
|
||||||
return this.requestPR(resource);
|
return this.requestPR(resource);
|
||||||
case Resource.Issue:
|
case UnfurlResourceType.Issue:
|
||||||
return this.requestIssue(resource);
|
return this.requestIssue(resource);
|
||||||
default:
|
default:
|
||||||
return { data: undefined };
|
return { data: undefined };
|
||||||
@@ -119,57 +91,7 @@ export class GitHub {
|
|||||||
|
|
||||||
private static appOctokit: Octokit;
|
private static appOctokit: Octokit;
|
||||||
|
|
||||||
private static supportedResources = Object.values(Resource);
|
private static supportedResources = Object.values(UnfurlResourceType);
|
||||||
|
|
||||||
private static transformPRData = (
|
|
||||||
resource: ReturnType<typeof GitHub.parseUrl>,
|
|
||||||
data: Record<string, any>
|
|
||||||
): PreviewData[Resource.PR] => ({
|
|
||||||
url: resource!.url,
|
|
||||||
type: Resource.PR,
|
|
||||||
title: data.title,
|
|
||||||
description: data.body,
|
|
||||||
author: {
|
|
||||||
name: data.user.login,
|
|
||||||
avatarUrl: data.user.avatar_url,
|
|
||||||
},
|
|
||||||
createdAt: data.created_at,
|
|
||||||
meta: {
|
|
||||||
identifier: `#${data.number}`,
|
|
||||||
status: {
|
|
||||||
name: data.merged ? "merged" : data.state,
|
|
||||||
color: GitHubUtils.getColorForStatus(
|
|
||||||
data.merged ? "merged" : data.state
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
private static transformIssueData = (
|
|
||||||
resource: ReturnType<typeof GitHub.parseUrl>,
|
|
||||||
data: Record<string, any>
|
|
||||||
): PreviewData[Resource.Issue] => ({
|
|
||||||
url: resource!.url,
|
|
||||||
type: Resource.Issue,
|
|
||||||
title: data.title,
|
|
||||||
description: data.body_text,
|
|
||||||
author: {
|
|
||||||
name: data.user.login,
|
|
||||||
avatarUrl: data.user.avatar_url,
|
|
||||||
},
|
|
||||||
createdAt: data.created_at,
|
|
||||||
meta: {
|
|
||||||
identifier: `#${data.number}`,
|
|
||||||
labels: data.labels.map((label: { name: string; color: string }) => ({
|
|
||||||
name: label.name,
|
|
||||||
color: `#${label.color}`,
|
|
||||||
})),
|
|
||||||
status: {
|
|
||||||
name: data.state,
|
|
||||||
color: GitHubUtils.getColorForStatus(data.state),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a given URL and returns resource identifiers for GitHub specific URLs
|
* Parses a given URL and returns resource identifiers for GitHub specific URLs
|
||||||
@@ -187,7 +109,7 @@ export class GitHub {
|
|||||||
const owner = parts[1];
|
const owner = parts[1];
|
||||||
const repo = parts[2];
|
const repo = parts[2];
|
||||||
const type = parts[3]
|
const type = parts[3]
|
||||||
? (pluralize.singular(parts[3]) as Resource)
|
? (pluralize.singular(parts[3]) as UnfurlResourceType)
|
||||||
: undefined;
|
: undefined;
|
||||||
const id = parts[4];
|
const id = parts[4];
|
||||||
|
|
||||||
@@ -253,38 +175,13 @@ export class GitHub {
|
|||||||
}),
|
}),
|
||||||
}) as Promise<InstanceType<typeof CustomOctokit>>;
|
}) as Promise<InstanceType<typeof CustomOctokit>>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms resource data obtained from GitHub to our own pre-defined preview data
|
|
||||||
* which will be consumed by our API clients
|
|
||||||
*
|
|
||||||
* @param resourceType Resource type for which to transform the data, e.g, an issue
|
|
||||||
* @param data Resource data obtained from GitHub via REST calls
|
|
||||||
* @returns {PreviewData} Transformed data suitable for our API clients
|
|
||||||
*/
|
|
||||||
public static transformResourceData = (
|
|
||||||
resource: ReturnType<typeof GitHub.parseUrl>,
|
|
||||||
data: Record<string, any>
|
|
||||||
) => {
|
|
||||||
switch (resource?.type) {
|
|
||||||
case Resource.PR:
|
|
||||||
return GitHub.transformPRData(resource, data);
|
|
||||||
case Resource.Issue:
|
|
||||||
return GitHub.transformIssueData(resource, data);
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param url GitHub resource url
|
* @param url GitHub resource url
|
||||||
* @param actor User attempting to unfurl resource url
|
* @param actor User attempting to unfurl resource url
|
||||||
* @returns {object} An object containing resource details e.g, a GitHub Pull Request details
|
* @returns An object containing resource details e.g, a GitHub Pull Request details
|
||||||
*/
|
*/
|
||||||
public static unfurl = async (
|
public static unfurl: UnfurlSignature = async (url: string, actor: User) => {
|
||||||
url: string,
|
|
||||||
actor: User
|
|
||||||
): Promise<Unfurl | undefined> => {
|
|
||||||
const resource = GitHub.parseUrl(url);
|
const resource = GitHub.parseUrl(url);
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
@@ -311,7 +208,7 @@ export class GitHub {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return GitHub.transformResourceData(resource, data);
|
return { ...data, type: resource.type };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.warn("Failed to fetch resource from GitHub", err);
|
Logger.warn("Failed to fetch resource from GitHub", err);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { Unfurl } from "@shared/types";
|
import { JSONObject, UnfurlResourceType } from "@shared/types";
|
||||||
import { InternalError } from "@server/errors";
|
import Logger from "@server/logging/Logger";
|
||||||
|
import { UnfurlSignature } from "@server/types";
|
||||||
import fetch from "@server/utils/fetch";
|
import fetch from "@server/utils/fetch";
|
||||||
import env from "./env";
|
import env from "./env";
|
||||||
|
|
||||||
class Iframely {
|
class Iframely {
|
||||||
public static defaultUrl = "https://iframe.ly";
|
public static defaultUrl = "https://iframe.ly";
|
||||||
|
|
||||||
public static async fetch(
|
public static async requestResource(
|
||||||
url: string,
|
url: string,
|
||||||
type = "oembed"
|
type = "oembed"
|
||||||
): Promise<Unfurl | void> {
|
): Promise<JSONObject | undefined> {
|
||||||
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
|
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
|
||||||
|
|
||||||
// Cloud Iframely requires /api path, while self-hosted does not.
|
// Cloud Iframely requires /api path, while self-hosted does not.
|
||||||
@@ -23,19 +24,20 @@ class Iframely {
|
|||||||
);
|
);
|
||||||
return res.json();
|
return res.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw InternalError(err);
|
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the preview data for the given url using Iframely oEmbed API
|
|
||||||
*
|
*
|
||||||
* @param url
|
* @param url Resource url
|
||||||
* @returns Preview data for the url
|
* @returns An object containing resource details e.g, resource title, description etc.
|
||||||
*/
|
*/
|
||||||
public static async unfurl(url: string): Promise<Unfurl | void> {
|
public static unfurl: UnfurlSignature = async (url: string) => {
|
||||||
return Iframely.fetch(url);
|
const data = await Iframely.requestResource(url);
|
||||||
}
|
return { ...data, type: UnfurlResourceType.OEmbed };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Iframely;
|
export default Iframely;
|
||||||
|
|||||||
209
server/presenters/unfurl.ts
Normal file
209
server/presenters/unfurl.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { differenceInMinutes, formatDistanceToNowStrict } from "date-fns";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||||
|
import { dateLocale } from "@shared/utils/date";
|
||||||
|
import { Document, User, View } from "@server/models";
|
||||||
|
import { opts } from "@server/utils/i18n";
|
||||||
|
import { GitHubUtils } from "plugins/github/shared/GitHubUtils";
|
||||||
|
|
||||||
|
async function presentUnfurl(data: Record<string, any>) {
|
||||||
|
switch (data.type) {
|
||||||
|
case UnfurlResourceType.Mention:
|
||||||
|
return presentMention(data);
|
||||||
|
case UnfurlResourceType.Document:
|
||||||
|
return presentDocument(data);
|
||||||
|
case UnfurlResourceType.PR:
|
||||||
|
return presentPR(data);
|
||||||
|
case UnfurlResourceType.Issue:
|
||||||
|
return presentIssue(data);
|
||||||
|
default:
|
||||||
|
return presentOEmbed(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentOEmbed = (
|
||||||
|
data: Record<string, any>
|
||||||
|
): UnfurlResponse[UnfurlResourceType.OEmbed] => ({
|
||||||
|
type: UnfurlResourceType.OEmbed,
|
||||||
|
url: data.url,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
thumbnailUrl: data.thumbnail_url,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presentMention = async (
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<UnfurlResponse[UnfurlResourceType.Mention]> => {
|
||||||
|
const user: User = data.user;
|
||||||
|
const document: Document = data.document;
|
||||||
|
|
||||||
|
const lastOnlineInfo = presentLastOnlineInfoFor(user);
|
||||||
|
const lastViewedInfo = await presentLastViewedInfoFor(user, document);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: UnfurlResourceType.Mention,
|
||||||
|
name: user.name,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
color: user.color,
|
||||||
|
lastActive: `${lastOnlineInfo} • ${lastViewedInfo}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const presentDocument = (
|
||||||
|
data: Record<string, any>
|
||||||
|
): UnfurlResponse[UnfurlResourceType.Document] => {
|
||||||
|
const document: Document = data.document;
|
||||||
|
const viewer: User = data.viewer;
|
||||||
|
return {
|
||||||
|
url: document.url,
|
||||||
|
type: UnfurlResourceType.Document,
|
||||||
|
id: document.id,
|
||||||
|
title: document.titleWithDefault,
|
||||||
|
summary: document.getSummary(),
|
||||||
|
lastActivityByViewer: presentLastActivityInfoFor(document, viewer),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const presentPR = (
|
||||||
|
data: Record<string, any>
|
||||||
|
): UnfurlResponse[UnfurlResourceType.PR] => ({
|
||||||
|
url: data.html_url,
|
||||||
|
type: UnfurlResourceType.PR,
|
||||||
|
id: `#${data.number}`,
|
||||||
|
title: data.title,
|
||||||
|
description: data.body,
|
||||||
|
author: {
|
||||||
|
name: data.user.login,
|
||||||
|
avatarUrl: data.user.avatar_url,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
name: data.merged ? "merged" : data.state,
|
||||||
|
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
|
||||||
|
},
|
||||||
|
createdAt: data.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presentIssue = (
|
||||||
|
data: Record<string, any>
|
||||||
|
): UnfurlResponse[UnfurlResourceType.Issue] => ({
|
||||||
|
url: data.html_url,
|
||||||
|
type: UnfurlResourceType.Issue,
|
||||||
|
id: `#${data.number}`,
|
||||||
|
title: data.title,
|
||||||
|
description: data.body_text,
|
||||||
|
author: {
|
||||||
|
name: data.user.login,
|
||||||
|
avatarUrl: data.user.avatar_url,
|
||||||
|
},
|
||||||
|
labels: data.labels.map((label: { name: string; color: string }) => ({
|
||||||
|
name: label.name,
|
||||||
|
color: `#${label.color}`,
|
||||||
|
})),
|
||||||
|
state: {
|
||||||
|
name: data.state,
|
||||||
|
color: GitHubUtils.getColorForStatus(data.state),
|
||||||
|
},
|
||||||
|
createdAt: data.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const presentLastViewedInfoFor = async (user: User, document: Document) => {
|
||||||
|
const lastView = await View.findOne({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
order: [["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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default presentUnfurl;
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { differenceInMinutes, formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { dateLocale } from "@shared/utils/date";
|
|
||||||
import { Document, User, View } 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 = async (
|
|
||||||
user: User,
|
|
||||||
document: Document
|
|
||||||
) => {
|
|
||||||
const lastView = await View.findOne({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
order: [["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;
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { UnfurlResponse, UnfurlType } from "@shared/types";
|
|
||||||
import { User, Document } from "@server/models";
|
|
||||||
import { presentLastActivityInfoFor } from "./common";
|
|
||||||
|
|
||||||
function presentDocument(
|
|
||||||
document: Document,
|
|
||||||
viewer: User
|
|
||||||
): UnfurlResponse<UnfurlType.Document> {
|
|
||||||
return {
|
|
||||||
url: document.url,
|
|
||||||
type: UnfurlType.Document,
|
|
||||||
title: document.titleWithDefault,
|
|
||||||
description: document.getSummary(),
|
|
||||||
meta: {
|
|
||||||
id: document.id,
|
|
||||||
info: presentLastActivityInfoFor(document, viewer),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default presentDocument;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import presentDocument from "./document";
|
|
||||||
import presentMention from "./mention";
|
|
||||||
|
|
||||||
export { presentDocument, presentMention };
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { UnfurlResponse, UnfurlType } from "@shared/types";
|
|
||||||
import { Document, User } from "@server/models";
|
|
||||||
import { presentLastOnlineInfoFor, presentLastViewedInfoFor } from "./common";
|
|
||||||
|
|
||||||
async function presentMention(
|
|
||||||
user: User,
|
|
||||||
document: Document
|
|
||||||
): Promise<UnfurlResponse<UnfurlType.Mention>> {
|
|
||||||
const lastOnlineInfo = presentLastOnlineInfoFor(user);
|
|
||||||
const lastViewedInfo = await presentLastViewedInfoFor(user, document);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: UnfurlType.Mention,
|
|
||||||
title: user.name,
|
|
||||||
thumbnailUrl: user.avatarUrl,
|
|
||||||
meta: {
|
|
||||||
id: user.id,
|
|
||||||
color: user.color,
|
|
||||||
info: `${lastOnlineInfo} • ${lastViewedInfo}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default presentMention;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { UnfurlResponse } from "@shared/types";
|
|
||||||
|
|
||||||
function presentUnfurl(data: any): UnfurlResponse {
|
|
||||||
return {
|
|
||||||
url: data.url,
|
|
||||||
type: data.type,
|
|
||||||
title: data.title,
|
|
||||||
createdAt: data.createdAt,
|
|
||||||
description: data.description,
|
|
||||||
thumbnailUrl: data.thumbnail_url,
|
|
||||||
author: data.author,
|
|
||||||
meta: data.meta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default presentUnfurl;
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { UnfurlResourceType } from "@shared/types";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import { User } from "@server/models";
|
import { User } from "@server/models";
|
||||||
import { buildDocument, buildUser } from "@server/test/factories";
|
import { buildDocument, buildUser } from "@server/test/factories";
|
||||||
@@ -18,7 +19,7 @@ jest.mock("dns", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(Iframely, "fetch")
|
.spyOn(Iframely, "requestResource")
|
||||||
.mockImplementation(() => Promise.resolve(undefined));
|
.mockImplementation(() => Promise.resolve(undefined));
|
||||||
|
|
||||||
const server = getTestServer();
|
const server = getTestServer();
|
||||||
@@ -133,9 +134,8 @@ describe("#urls.unfurl", () => {
|
|||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.type).toEqual("mention");
|
expect(body.type).toEqual(UnfurlResourceType.Mention);
|
||||||
expect(body.title).toEqual(mentionedUser.name);
|
expect(body.name).toEqual(mentionedUser.name);
|
||||||
expect(body.meta.id).toEqual(mentionedUser.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed with status 200 ok when valid document url is supplied", async () => {
|
it("should succeed with status 200 ok when valid document url is supplied", async () => {
|
||||||
@@ -152,13 +152,13 @@ describe("#urls.unfurl", () => {
|
|||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.type).toEqual("document");
|
expect(body.type).toEqual(UnfurlResourceType.Document);
|
||||||
expect(body.title).toEqual(document.titleWithDefault);
|
expect(body.title).toEqual(document.titleWithDefault);
|
||||||
expect(body.meta.id).toEqual(document.id);
|
expect(body.id).toEqual(document.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed with status 200 ok for a valid external url", async () => {
|
it("should succeed with status 200 ok for a valid external url", async () => {
|
||||||
(Iframely.fetch as jest.Mock).mockResolvedValue(
|
(Iframely.requestResource as jest.Mock).mockResolvedValue(
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
url: "https://www.flickr.com",
|
url: "https://www.flickr.com",
|
||||||
type: "rich",
|
type: "rich",
|
||||||
@@ -182,7 +182,7 @@ describe("#urls.unfurl", () => {
|
|||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.url).toEqual("https://www.flickr.com");
|
expect(body.url).toEqual("https://www.flickr.com");
|
||||||
expect(body.type).toEqual("rich");
|
expect(body.type).toEqual(UnfurlResourceType.OEmbed);
|
||||||
expect(body.title).toEqual("Flickr");
|
expect(body.title).toEqual("Flickr");
|
||||||
expect(body.description).toEqual(
|
expect(body.description).toEqual(
|
||||||
"The safest and most inclusive global community of photography enthusiasts. The best place for inspiration, connection, and sharing!"
|
"The safest and most inclusive global community of photography enthusiasts. The best place for inspiration, connection, and sharing!"
|
||||||
@@ -193,7 +193,7 @@ describe("#urls.unfurl", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed with status 204 no content for a non-existing external url", async () => {
|
it("should succeed with status 204 no content for a non-existing external url", async () => {
|
||||||
(Iframely.fetch as jest.Mock).mockResolvedValue(
|
(Iframely.requestResource as jest.Mock).mockResolvedValue(
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
status: 404,
|
status: 404,
|
||||||
error:
|
error:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import dns from "dns";
|
import dns from "dns";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import { UnfurlResourceType } from "@shared/types";
|
||||||
import { getBaseDomain, parseDomain } from "@shared/utils/domains";
|
import { getBaseDomain, parseDomain } from "@shared/utils/domains";
|
||||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||||
import parseMentionUrl from "@shared/utils/parseMentionUrl";
|
import parseMentionUrl from "@shared/utils/parseMentionUrl";
|
||||||
@@ -10,8 +11,7 @@ import { rateLimiter } from "@server/middlewares/rateLimiter";
|
|||||||
import validate from "@server/middlewares/validate";
|
import validate from "@server/middlewares/validate";
|
||||||
import { Document, Share, Team, User } from "@server/models";
|
import { Document, Share, Team, User } from "@server/models";
|
||||||
import { authorize } from "@server/policies";
|
import { authorize } from "@server/policies";
|
||||||
import { presentDocument, presentMention } from "@server/presenters/unfurls";
|
import presentUnfurl from "@server/presenters/unfurl";
|
||||||
import presentUnfurl from "@server/presenters/unfurls/unfurl";
|
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||||
@@ -53,7 +53,11 @@ router.post(
|
|||||||
authorize(actor, "read", user);
|
authorize(actor, "read", user);
|
||||||
authorize(actor, "read", document);
|
authorize(actor, "read", document);
|
||||||
|
|
||||||
ctx.body = await presentMention(user, document);
|
ctx.body = await presentUnfurl({
|
||||||
|
type: UnfurlResourceType.Mention,
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +73,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
authorize(actor, "read", document);
|
authorize(actor, "read", document);
|
||||||
|
|
||||||
ctx.body = presentDocument(document, actor);
|
ctx.body = await presentUnfurl({
|
||||||
|
type: UnfurlResourceType.Document,
|
||||||
|
document,
|
||||||
|
viewer: actor,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return (ctx.response.status = 204);
|
return (ctx.response.status = 204);
|
||||||
@@ -80,7 +88,7 @@ router.post(
|
|||||||
CacheHelper.getUnfurlKey(actor.teamId, url)
|
CacheHelper.getUnfurlKey(actor.teamId, url)
|
||||||
);
|
);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return (ctx.body = presentUnfurl(cachedData));
|
return (ctx.body = await presentUnfurl(cachedData));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
@@ -94,7 +102,7 @@ router.post(
|
|||||||
data,
|
data,
|
||||||
plugin.value.cacheExpiry
|
plugin.value.cacheExpiry
|
||||||
);
|
);
|
||||||
return (ctx.body = presentUnfurl(data));
|
return (ctx.body = await presentUnfurl(data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Client,
|
Client,
|
||||||
CollectionPermission,
|
CollectionPermission,
|
||||||
DocumentPermission,
|
DocumentPermission,
|
||||||
|
JSONValue,
|
||||||
|
UnfurlResourceType,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { BaseSchema } from "@server/routes/api/schema";
|
import { BaseSchema } from "@server/routes/api/schema";
|
||||||
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||||
@@ -507,6 +509,11 @@ export type CollectionJSONExport = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UnfurlResolver = {
|
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
|
||||||
unfurl: (url: string, actor?: User) => Promise<any>;
|
|
||||||
};
|
export type UnfurlSignature = (
|
||||||
|
url: string,
|
||||||
|
actor?: User
|
||||||
|
) => Promise<Unfurl | void>;
|
||||||
|
|
||||||
|
export type UninstallSignature = (integration: Integration) => Promise<void>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Day } from "@shared/utils/time";
|
import { Day } from "@shared/utils/time";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
import Redis from "@server/storage/redis";
|
import Redis from "@server/storage/redis";
|
||||||
|
import { Unfurl, UnfurlSignature } from "@server/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Helper class for server-side cache management
|
* A Helper class for server-side cache management
|
||||||
@@ -13,7 +14,7 @@ export class CacheHelper {
|
|||||||
*
|
*
|
||||||
* @param key Key against which data will be accessed
|
* @param key Key against which data will be accessed
|
||||||
*/
|
*/
|
||||||
public static async getData(key: string) {
|
public static async getData(key: string): ReturnType<UnfurlSignature> {
|
||||||
try {
|
try {
|
||||||
const data = await Redis.defaultClient.get(key);
|
const data = await Redis.defaultClient.get(key);
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -21,7 +22,10 @@ export class CacheHelper {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// just log it, response can still be obtained using the fetch call
|
// just log it, response can still be obtained using the fetch call
|
||||||
Logger.error(`Could not fetch cached response against ${key}`, err);
|
return Logger.error(
|
||||||
|
`Could not fetch cached response against ${key}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,11 +36,7 @@ export class CacheHelper {
|
|||||||
* @param data Data to be saved against the key
|
* @param data Data to be saved against the key
|
||||||
* @param expiry Cache data expiry
|
* @param expiry Cache data expiry
|
||||||
*/
|
*/
|
||||||
public static async setData(
|
public static async setData(key: string, data: Unfurl, expiry?: number) {
|
||||||
key: string,
|
|
||||||
data: Record<string, any>,
|
|
||||||
expiry?: number
|
|
||||||
) {
|
|
||||||
if ("error" in data) {
|
if ("error" in data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { glob } from "glob";
|
|||||||
import type Router from "koa-router";
|
import type Router from "koa-router";
|
||||||
import isArray from "lodash/isArray";
|
import isArray from "lodash/isArray";
|
||||||
import sortBy from "lodash/sortBy";
|
import sortBy from "lodash/sortBy";
|
||||||
import { UnfurlSignature, UninstallSignature } from "@shared/types";
|
|
||||||
import type BaseEmail from "@server/emails/templates/BaseEmail";
|
import type BaseEmail from "@server/emails/templates/BaseEmail";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||||
import type BaseTask from "@server/queues/tasks/BaseTask";
|
import type BaseTask from "@server/queues/tasks/BaseTask";
|
||||||
|
import { UnfurlSignature, UninstallSignature } from "@server/types";
|
||||||
|
|
||||||
export enum PluginPriority {
|
export enum PluginPriority {
|
||||||
VeryHigh = 0,
|
VeryHigh = 0,
|
||||||
|
|||||||
112
shared/types.ts
112
shared/types.ts
@@ -272,45 +272,97 @@ export const NotificationEventDefaults = {
|
|||||||
[NotificationEventType.AddUserToCollection]: true,
|
[NotificationEventType.AddUserToCollection]: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum UnfurlType {
|
export enum UnfurlResourceType {
|
||||||
|
OEmbed = "oembed",
|
||||||
Mention = "mention",
|
Mention = "mention",
|
||||||
Document = "document",
|
Document = "document",
|
||||||
Issue = "issue",
|
Issue = "issue",
|
||||||
Pull = "pull",
|
PR = "pull",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UnfurlResponse = {
|
||||||
|
[UnfurlResourceType.OEmbed]: {
|
||||||
|
/** The resource type */
|
||||||
|
type: UnfurlResourceType.OEmbed;
|
||||||
|
/** URL pointing to the resource */
|
||||||
|
url: string;
|
||||||
|
/** A text title, describing the resource */
|
||||||
|
title: string;
|
||||||
|
/** A brief description about the resource */
|
||||||
|
description: string;
|
||||||
|
/** A URL to a thumbnail image representing the resource */
|
||||||
|
thumbnailUrl: string;
|
||||||
|
};
|
||||||
|
[UnfurlResourceType.Mention]: {
|
||||||
|
/** The resource type */
|
||||||
|
type: UnfurlResourceType.Mention;
|
||||||
|
/** Mentioned user's name */
|
||||||
|
name: string;
|
||||||
|
/** Mentioned user's avatar URL */
|
||||||
|
avatarUrl: string | null;
|
||||||
|
/** Used to create mentioned user's avatar if no avatar URL provided */
|
||||||
|
color: string;
|
||||||
|
/** Mentiond user's recent activity */
|
||||||
|
lastActive: string;
|
||||||
|
};
|
||||||
|
[UnfurlResourceType.Document]: {
|
||||||
|
/** The resource type */
|
||||||
|
type: UnfurlResourceType.Document;
|
||||||
|
/** URL pointing to the resource */
|
||||||
|
url: string;
|
||||||
|
/** Document id */
|
||||||
|
id: string;
|
||||||
|
/** Document title */
|
||||||
|
title: string;
|
||||||
|
/** Document summary */
|
||||||
|
summary: string;
|
||||||
|
/** Viewer's last activity on this document */
|
||||||
|
lastActivityByViewer: string;
|
||||||
|
};
|
||||||
|
[UnfurlResourceType.Issue]: {
|
||||||
|
/** The resource type */
|
||||||
|
type: UnfurlResourceType.Issue;
|
||||||
|
/** Issue link */
|
||||||
|
url: string;
|
||||||
|
/** Issue identifier */
|
||||||
|
id: string;
|
||||||
|
/** Issue title */
|
||||||
|
title: string;
|
||||||
|
/** Issue description */
|
||||||
|
description: string;
|
||||||
|
/** Issue's author */
|
||||||
|
author: { name: string; avatarUrl: string };
|
||||||
|
/** Issue's labels */
|
||||||
|
labels: Array<{ name: string; color: string }>;
|
||||||
|
/** Issue's status */
|
||||||
|
state: { name: string; color: string };
|
||||||
|
/** Issue's creation time */
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
[UnfurlResourceType.PR]: {
|
||||||
|
/** The resource type */
|
||||||
|
type: UnfurlResourceType.PR;
|
||||||
|
/** Pull Request link */
|
||||||
|
url: string;
|
||||||
|
/** Pull Request identifier */
|
||||||
|
id: string;
|
||||||
|
/** Pull Request title */
|
||||||
|
title: string;
|
||||||
|
/** Pull Request description */
|
||||||
|
description: string;
|
||||||
|
/** Pull Request author */
|
||||||
|
author: { name: string; avatarUrl: string };
|
||||||
|
/** Pull Request status */
|
||||||
|
state: { name: string; color: string };
|
||||||
|
/** Pull Request creation time */
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export enum QueryNotices {
|
export enum QueryNotices {
|
||||||
UnsubscribeDocument = "unsubscribe-document",
|
UnsubscribeDocument = "unsubscribe-document",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OEmbedType = "photo" | "video" | "rich";
|
|
||||||
|
|
||||||
export type UnfurlResponse<S = OEmbedType, T = Record<string, any>> = {
|
|
||||||
url?: string;
|
|
||||||
type: S | ("issue" | "pull" | "commit");
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
thumbnailUrl?: string | null;
|
|
||||||
author?: { name: string; avatarUrl: string };
|
|
||||||
meta?: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Unfurl =
|
|
||||||
| UnfurlResponse
|
|
||||||
| {
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlSignature = (
|
|
||||||
url: string,
|
|
||||||
actor?: any
|
|
||||||
) => Promise<Unfurl | void>;
|
|
||||||
|
|
||||||
export type UninstallSignature = (
|
|
||||||
integration: Record<string, any>
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
export type JSONValue =
|
export type JSONValue =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
|
|||||||
Reference in New Issue
Block a user