Refactor unfurling related types (#6730)
* wip * fix: refactor unfurl types
This commit is contained in:
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 { User } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
@@ -18,7 +19,7 @@ jest.mock("dns", () => ({
|
||||
}));
|
||||
|
||||
jest
|
||||
.spyOn(Iframely, "fetch")
|
||||
.spyOn(Iframely, "requestResource")
|
||||
.mockImplementation(() => Promise.resolve(undefined));
|
||||
|
||||
const server = getTestServer();
|
||||
@@ -133,9 +134,8 @@ describe("#urls.unfurl", () => {
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.type).toEqual("mention");
|
||||
expect(body.title).toEqual(mentionedUser.name);
|
||||
expect(body.meta.id).toEqual(mentionedUser.id);
|
||||
expect(body.type).toEqual(UnfurlResourceType.Mention);
|
||||
expect(body.name).toEqual(mentionedUser.name);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.type).toEqual("document");
|
||||
expect(body.type).toEqual(UnfurlResourceType.Document);
|
||||
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 () => {
|
||||
(Iframely.fetch as jest.Mock).mockResolvedValue(
|
||||
(Iframely.requestResource as jest.Mock).mockResolvedValue(
|
||||
Promise.resolve({
|
||||
url: "https://www.flickr.com",
|
||||
type: "rich",
|
||||
@@ -182,7 +182,7 @@ describe("#urls.unfurl", () => {
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
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.description).toEqual(
|
||||
"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 () => {
|
||||
(Iframely.fetch as jest.Mock).mockResolvedValue(
|
||||
(Iframely.requestResource as jest.Mock).mockResolvedValue(
|
||||
Promise.resolve({
|
||||
status: 404,
|
||||
error:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dns from "dns";
|
||||
import Router from "koa-router";
|
||||
import { UnfurlResourceType } from "@shared/types";
|
||||
import { getBaseDomain, parseDomain } from "@shared/utils/domains";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import parseMentionUrl from "@shared/utils/parseMentionUrl";
|
||||
@@ -10,8 +11,7 @@ import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Document, Share, Team, User } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentDocument, presentMention } from "@server/presenters/unfurls";
|
||||
import presentUnfurl from "@server/presenters/unfurls/unfurl";
|
||||
import presentUnfurl from "@server/presenters/unfurl";
|
||||
import { APIContext } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
@@ -53,7 +53,11 @@ router.post(
|
||||
authorize(actor, "read", user);
|
||||
authorize(actor, "read", document);
|
||||
|
||||
ctx.body = await presentMention(user, document);
|
||||
ctx.body = await presentUnfurl({
|
||||
type: UnfurlResourceType.Mention,
|
||||
user,
|
||||
document,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,7 +73,11 @@ router.post(
|
||||
}
|
||||
authorize(actor, "read", document);
|
||||
|
||||
ctx.body = presentDocument(document, actor);
|
||||
ctx.body = await presentUnfurl({
|
||||
type: UnfurlResourceType.Document,
|
||||
document,
|
||||
viewer: actor,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return (ctx.response.status = 204);
|
||||
@@ -80,7 +88,7 @@ router.post(
|
||||
CacheHelper.getUnfurlKey(actor.teamId, url)
|
||||
);
|
||||
if (cachedData) {
|
||||
return (ctx.body = presentUnfurl(cachedData));
|
||||
return (ctx.body = await presentUnfurl(cachedData));
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
@@ -94,7 +102,7 @@ router.post(
|
||||
data,
|
||||
plugin.value.cacheExpiry
|
||||
);
|
||||
return (ctx.body = presentUnfurl(data));
|
||||
return (ctx.body = await presentUnfurl(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Client,
|
||||
CollectionPermission,
|
||||
DocumentPermission,
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||
@@ -507,6 +509,11 @@ export type CollectionJSONExport = {
|
||||
};
|
||||
};
|
||||
|
||||
export type UnfurlResolver = {
|
||||
unfurl: (url: string, actor?: User) => Promise<any>;
|
||||
};
|
||||
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
|
||||
|
||||
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 Logger from "@server/logging/Logger";
|
||||
import Redis from "@server/storage/redis";
|
||||
import { Unfurl, UnfurlSignature } from "@server/types";
|
||||
|
||||
/**
|
||||
* A Helper class for server-side cache management
|
||||
@@ -13,7 +14,7 @@ export class CacheHelper {
|
||||
*
|
||||
* @param key Key against which data will be accessed
|
||||
*/
|
||||
public static async getData(key: string) {
|
||||
public static async getData(key: string): ReturnType<UnfurlSignature> {
|
||||
try {
|
||||
const data = await Redis.defaultClient.get(key);
|
||||
if (data) {
|
||||
@@ -21,7 +22,10 @@ export class CacheHelper {
|
||||
}
|
||||
} catch (err) {
|
||||
// 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 expiry Cache data expiry
|
||||
*/
|
||||
public static async setData(
|
||||
key: string,
|
||||
data: Record<string, any>,
|
||||
expiry?: number
|
||||
) {
|
||||
public static async setData(key: string, data: Unfurl, expiry?: number) {
|
||||
if ("error" in data) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { glob } from "glob";
|
||||
import type Router from "koa-router";
|
||||
import isArray from "lodash/isArray";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { UnfurlSignature, UninstallSignature } from "@shared/types";
|
||||
import type BaseEmail from "@server/emails/templates/BaseEmail";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { UnfurlSignature, UninstallSignature } from "@server/types";
|
||||
|
||||
export enum PluginPriority {
|
||||
VeryHigh = 0,
|
||||
|
||||
Reference in New Issue
Block a user