Refactor unfurling related types (#6730)

* wip

* fix: refactor unfurl types
This commit is contained in:
Apoorv Mishra
2024-04-03 07:28:30 +05:30
committed by GitHub
parent e0ae044f4c
commit 6a4628afef
19 changed files with 399 additions and 457 deletions

View File

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

View File

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

View File

@@ -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}&nbsp;<Text type="tertiary">{identifier}</Text> {title}&nbsp;<Text type="tertiary">{id}</Text>
</span> </span>
</Title> </Title>
<Flex align="center" gap={4}> <Flex align="center" gap={4}>

View File

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

View File

@@ -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}&nbsp;<Text type="tertiary">{identifier}</Text> {title}&nbsp;<Text type="tertiary">{id}</Text>
</span> </span>
</Title> </Title>
<Flex align="center" gap={4}> <Flex align="center" gap={4}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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